|
|
@@ -84,7 +84,7 @@ def _check_implicit_terminate(thought: str) -> bool:
|
|
|
# ============================================================
|
|
|
|
|
|
_MAX_STATE_WINDOW = 3 # Keep last N steps in full
|
|
|
-_MAX_OBS_LENGTH = 800 # Truncate observations
|
|
|
+_MAX_OBS_LENGTH = 3000 # Truncate observations (800 was too small, causing retry loops)
|
|
|
_MAX_THOUGHT_LENGTH = 500 # Truncate thoughts in history
|
|
|
|
|
|
|
|
|
@@ -114,11 +114,23 @@ def _compress_state(state: str, thought: str, tool_name: str, observation: str)
|
|
|
|
|
|
# Add new step
|
|
|
step_count = len(steps) + 1
|
|
|
+
|
|
|
+ # Anti-hallucination: if tool returned an error, add explicit retry instruction
|
|
|
+ error_prefixes = ("[VALIDATION_ERROR]", "[TOOL_ERROR]", "[ERROR]", "[MCP_ERROR]", "[MCP_TIMEOUT]")
|
|
|
+ is_error = any(observation.startswith(p) for p in error_prefixes)
|
|
|
+ error_hint = ""
|
|
|
+ if is_error:
|
|
|
+ error_hint = (
|
|
|
+ "\n[SYSTEM] The tool call FAILED. You have NOT seen the actual data. "
|
|
|
+ "Do NOT fabricate or guess results. Fix the tool call parameters and retry. "
|
|
|
+ "If unsure about parameters, try a different approach."
|
|
|
+ )
|
|
|
+
|
|
|
new_step = (
|
|
|
f"[Step {step_count}]\n"
|
|
|
f"Thought: {thought}\n"
|
|
|
f"Action: {tool_name}\n"
|
|
|
- f"Observation: {observation}"
|
|
|
+ f"Observation: {observation}{error_hint}"
|
|
|
)
|
|
|
steps.append(new_step)
|
|
|
|
|
|
@@ -183,6 +195,9 @@ def from_config(path: str, **overrides) -> Term:
|
|
|
if "max_steps" in overrides:
|
|
|
cfg.setdefault("react", {})["maxSteps"] = overrides["max_steps"]
|
|
|
|
|
|
+ # Pass config directory for resolving relative paths (subAgents, etc.)
|
|
|
+ overrides.setdefault("_config_dir", str(os.path.dirname(os.path.abspath(path))))
|
|
|
+
|
|
|
# Schema validation
|
|
|
errors = validate_schema(cfg)
|
|
|
fatal = [e for e in errors if e[0] == "ERROR"]
|
|
|
@@ -201,6 +216,12 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None) -> Term:
|
|
|
overrides = overrides or {}
|
|
|
agent_type = cfg.get("type", "simple")
|
|
|
|
|
|
+ # Step 0: Compile subAgents if present (multi-agent orchestrator support)
|
|
|
+ # subAgents 节定义了子代理,编译后作为 call_* 工具注入到协调者
|
|
|
+ sub_agents_cfg = cfg.get("subAgents", {})
|
|
|
+ if sub_agents_cfg and "tools" not in overrides:
|
|
|
+ overrides = {**overrides, "tools": _compile_sub_agents(cfg, sub_agents_cfg, overrides)}
|
|
|
+
|
|
|
# Step 0a: Build ToolGateway from guard config (if present)
|
|
|
guard_cfg = cfg.get("guard") or {}
|
|
|
gateway = _build_gateway(guard_cfg, overrides)
|
|
|
@@ -216,7 +237,7 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None) -> Term:
|
|
|
|
|
|
# Step 1: Compile core agent based on type
|
|
|
if agent_type == "simple":
|
|
|
- agent = _compile_simple(cfg)
|
|
|
+ agent = _compile_simple(cfg, overrides)
|
|
|
elif agent_type == "react":
|
|
|
agent = _compile_react(cfg, overrides)
|
|
|
elif agent_type == "chain":
|
|
|
@@ -240,6 +261,147 @@ def build_agent(cfg: Dict[str, Any], overrides: Dict = None) -> Term:
|
|
|
return agent
|
|
|
|
|
|
|
|
|
+# ============================================================
|
|
|
+# SubAgent compiler (multi-agent orchestrator support)
|
|
|
+# ============================================================
|
|
|
+
|
|
|
+def _compile_sub_agents(cfg: Dict, sub_agents_cfg: Dict, overrides: Dict) -> Dict[str, Any]:
|
|
|
+ """
|
|
|
+ Compile subAgents section into callable tool functions.
|
|
|
+
|
|
|
+ Supports two modes:
|
|
|
+ 1. File reference: config: agents/code-agent.yml
|
|
|
+ 2. Inline config: inline: {type: react, systemPrompt: ..., ...}
|
|
|
+
|
|
|
+ When PaaS compiles via temp file, file references break (temp dir).
|
|
|
+ Inline mode always works regardless of where the YAML is compiled from.
|
|
|
+
|
|
|
+ subAgents:
|
|
|
+ code-agent:
|
|
|
+ config: agents/code-agent.yml # mode 1: file reference
|
|
|
+ inline: {type: react, ...} # mode 2: inline (preferred for PaaS)
|
|
|
+ description: "..."
|
|
|
+ tags: [...]
|
|
|
+ tool: call_code
|
|
|
+ """
|
|
|
+ tools = {}
|
|
|
+
|
|
|
+ config_dir = overrides.get("_config_dir", os.getcwd())
|
|
|
+
|
|
|
+ # Optional: register as Skills for reuse
|
|
|
+ try:
|
|
|
+ from lambdagent.skills import Skill, SkillSignature, SkillPack, SkillRegistry
|
|
|
+ pack = SkillPack(name="subAgents", description="Auto-compiled sub-agents", version="1.0.0")
|
|
|
+ registry = SkillRegistry()
|
|
|
+ has_skills = True
|
|
|
+ except ImportError:
|
|
|
+ has_skills = False
|
|
|
+
|
|
|
+ for agent_name, agent_def in sub_agents_cfg.items():
|
|
|
+ tool_name = agent_def.get("tool", f"call_{agent_name.replace('-agent', '')}")
|
|
|
+
|
|
|
+ # Determine how to compile this sub-agent
|
|
|
+ inline_cfg = agent_def.get("inline")
|
|
|
+ rel_path = agent_def.get("config", "")
|
|
|
+ agent_yml = os.path.join(config_dir, rel_path) if rel_path else ""
|
|
|
+
|
|
|
+ # Resolve source: inline > file > placeholder
|
|
|
+ has_inline = inline_cfg and isinstance(inline_cfg, dict) and "type" in inline_cfg
|
|
|
+ has_file = rel_path and os.path.isfile(agent_yml)
|
|
|
+
|
|
|
+ if not has_inline and not has_file:
|
|
|
+ tools[tool_name] = lambda x, _n=agent_name, _p=agent_yml: (
|
|
|
+ f"[SubAgent {_n} not found: no inline config and file not found at {_p}]"
|
|
|
+ )
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Lazy compilation cache (shared across calls)
|
|
|
+ _compiled_cache = {}
|
|
|
+
|
|
|
+ def _make_caller(_name, _path, _inline):
|
|
|
+ def caller(input_str):
|
|
|
+ if _name not in _compiled_cache:
|
|
|
+ try:
|
|
|
+ if _inline:
|
|
|
+ # Inline mode: compile from dict directly
|
|
|
+ _compiled_cache[_name] = build_agent(_inline, {})
|
|
|
+ else:
|
|
|
+ # File mode: compile from YAML path
|
|
|
+ _compiled_cache[_name] = from_config(_path)
|
|
|
+ except Exception as e:
|
|
|
+ return f"[SubAgent {_name} compile error: {e}]"
|
|
|
+
|
|
|
+ # Parse input
|
|
|
+ task = input_str
|
|
|
+ if isinstance(input_str, str):
|
|
|
+ try:
|
|
|
+ import json as _json
|
|
|
+ data = _json.loads(input_str)
|
|
|
+ task = data.get("task", input_str)
|
|
|
+ except (ValueError, AttributeError):
|
|
|
+ pass
|
|
|
+
|
|
|
+ try:
|
|
|
+ result = _compiled_cache[_name].apply(task, Context())
|
|
|
+ return str(result)
|
|
|
+ except Exception as e:
|
|
|
+ return f"[SubAgent {_name} error: {e}]"
|
|
|
+
|
|
|
+ return caller
|
|
|
+
|
|
|
+ tools[tool_name] = _make_caller(
|
|
|
+ agent_name,
|
|
|
+ agent_yml if has_file else "",
|
|
|
+ inline_cfg if has_inline else None,
|
|
|
+ )
|
|
|
+
|
|
|
+ # Register as Skill for reuse
|
|
|
+ if has_skills:
|
|
|
+ class _LazySubAgent(Term):
|
|
|
+ def __init__(self, name, path, inline):
|
|
|
+ super().__init__(name)
|
|
|
+ self._path = path
|
|
|
+ self._inline = inline
|
|
|
+ self._inner = None
|
|
|
+
|
|
|
+ def apply(self, input_val, ctx=None):
|
|
|
+ if self._inner is None:
|
|
|
+ if self._inline:
|
|
|
+ self._inner = build_agent(self._inline, {})
|
|
|
+ else:
|
|
|
+ self._inner = from_config(self._path)
|
|
|
+ ctx = ctx or Context()
|
|
|
+ return self._inner.apply(str(input_val), ctx)
|
|
|
+
|
|
|
+ lazy_term = _LazySubAgent(
|
|
|
+ agent_name,
|
|
|
+ agent_yml if has_file else "",
|
|
|
+ inline_cfg if has_inline else None,
|
|
|
+ )
|
|
|
+ skill = Skill(
|
|
|
+ name=agent_name,
|
|
|
+ term=lazy_term,
|
|
|
+ description=agent_def.get("description", ""),
|
|
|
+ signature=SkillSignature(input_type="Str", output_type="Str"),
|
|
|
+ tags=agent_def.get("tags", []),
|
|
|
+ version="2.0.0",
|
|
|
+ )
|
|
|
+ pack.add(skill)
|
|
|
+
|
|
|
+ # Register ToolSearch if available
|
|
|
+ try:
|
|
|
+ from agentexample.agent67v2.tools.tool_search import tool_search
|
|
|
+ tools["ToolSearch"] = lambda x: tool_search.apply(x)
|
|
|
+ except ImportError:
|
|
|
+ tools["ToolSearch"] = lambda x: "[ToolSearch not available]"
|
|
|
+
|
|
|
+ # Register skill pack
|
|
|
+ if has_skills and len(pack) > 0:
|
|
|
+ registry.register_pack(pack)
|
|
|
+
|
|
|
+ return tools
|
|
|
+
|
|
|
+
|
|
|
# ============================================================
|
|
|
# Type-specific compilers
|
|
|
# ============================================================
|
|
|
@@ -279,8 +441,46 @@ def _load_project_config() -> str:
|
|
|
return ""
|
|
|
|
|
|
|
|
|
-def _compile_lam(cfg: Dict, name_suffix: str = "") -> Lam:
|
|
|
- """Compile systemPrompt + model -> Lam (lambda abstraction)."""
|
|
|
+def _create_provider(model_cfg: Dict):
|
|
|
+ """Create an LLMProvider from model config. Returns (provider, is_conversation)."""
|
|
|
+ from lambdagent.providers.base import ProviderConfig
|
|
|
+
|
|
|
+ provider_name = model_cfg.get("provider", "anthropic")
|
|
|
+ model_name = model_cfg.get("name", "")
|
|
|
+ use_conversation = model_cfg.get("conversation", True)
|
|
|
+
|
|
|
+ config = ProviderConfig(
|
|
|
+ model=model_name,
|
|
|
+ temperature=model_cfg.get("temperature", 0.3),
|
|
|
+ max_tokens=model_cfg.get("maxTokens", 4096),
|
|
|
+ timeout=model_cfg.get("timeout", 600),
|
|
|
+ context_window=model_cfg.get("contextWindow", 200000),
|
|
|
+ )
|
|
|
+
|
|
|
+ if provider_name == "claude-code":
|
|
|
+ from lambdagent.providers.claude_code_provider import ClaudeCodeProvider
|
|
|
+ config.model = model_name or "sonnet"
|
|
|
+ return ClaudeCodeProvider(config), use_conversation
|
|
|
+
|
|
|
+ if provider_name == "anthropic":
|
|
|
+ from lambdagent.providers.anthropic_provider import AnthropicProvider
|
|
|
+ config.model = model_name or "claude-sonnet-4-20250514"
|
|
|
+ return AnthropicProvider(config), use_conversation
|
|
|
+
|
|
|
+ # OpenAI-compatible: ollama, openai, dashscope, deepseek, moonshot, zhipu
|
|
|
+ from lambdagent.providers.openai_compat_provider import OpenAICompatProvider
|
|
|
+ if not model_name:
|
|
|
+ _defaults = {"openai": "gpt-4o", "ollama": "qwen2.5:7b", "dashscope": "qwen-max"}
|
|
|
+ config.model = _defaults.get(provider_name, "gpt-4o")
|
|
|
+ # Context window hints for smaller models
|
|
|
+ _ctx_windows = {"ollama": 32000, "deepseek": 64000, "moonshot": 128000}
|
|
|
+ if provider_name in _ctx_windows:
|
|
|
+ config.context_window = _ctx_windows[provider_name]
|
|
|
+ return OpenAICompatProvider(config, provider_name=provider_name), use_conversation
|
|
|
+
|
|
|
+
|
|
|
+def _compile_lam(cfg: Dict, name_suffix: str = "", overrides: Dict = None) -> "Term":
|
|
|
+ """Compile systemPrompt + model -> ConversationLam (preferred) or Lam (fallback)."""
|
|
|
agent_name = cfg.get("name", cfg.get("agentId", "agent"))
|
|
|
if name_suffix:
|
|
|
agent_name = f"{agent_name}.{name_suffix}"
|
|
|
@@ -296,6 +496,23 @@ def _compile_lam(cfg: Dict, name_suffix: str = "") -> Lam:
|
|
|
|
|
|
prompt = _inject_resistant_prompt(raw_prompt)
|
|
|
|
|
|
+ # Try new provider system (ConversationLam)
|
|
|
+ try:
|
|
|
+ provider, use_conversation = _create_provider(model_cfg)
|
|
|
+
|
|
|
+ if use_conversation:
|
|
|
+ from lambdagent.conversation import ConversationLam
|
|
|
+ max_history = model_cfg.get("maxHistoryTokens", min(provider.context_window // 2, 80000))
|
|
|
+ return ConversationLam(
|
|
|
+ name=agent_name,
|
|
|
+ provider=provider,
|
|
|
+ system_prompt=prompt,
|
|
|
+ max_history_tokens=max_history,
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ pass # Fall through to legacy Lam
|
|
|
+
|
|
|
+ # Legacy fallback: stateless Lam
|
|
|
return Lam(
|
|
|
name=agent_name,
|
|
|
prompt=prompt,
|
|
|
@@ -305,9 +522,56 @@ def _compile_lam(cfg: Dict, name_suffix: str = "") -> Lam:
|
|
|
)
|
|
|
|
|
|
|
|
|
-def _compile_simple(cfg: Dict) -> Term:
|
|
|
+def _compile_simple(cfg: Dict, overrides: Dict = None) -> Term:
|
|
|
"""type: simple -> Lam(name, prompt, model)"""
|
|
|
- return _compile_lam(cfg)
|
|
|
+ return _compile_lam(cfg, overrides=overrides)
|
|
|
+
|
|
|
+
|
|
|
+def _generate_tool_schema_docs(cfg: Dict) -> str:
|
|
|
+ """Auto-generate tool parameter documentation from BUILTIN_TOOLS schemas.
|
|
|
+
|
|
|
+ Injects into the system prompt so the LLM knows exact parameter names
|
|
|
+ instead of guessing (which causes VALIDATION_ERROR → hallucination).
|
|
|
+ """
|
|
|
+ import inspect
|
|
|
+ try:
|
|
|
+ from lambdagent.builtin_tools.registry import BUILTIN_TOOLS
|
|
|
+ except ImportError:
|
|
|
+ return ""
|
|
|
+
|
|
|
+ tool_names = cfg.get("mcp", {}).get("localTools", [])
|
|
|
+ if not tool_names:
|
|
|
+ return ""
|
|
|
+
|
|
|
+ lines = ["\n\n## 工具参数参考 (Tool Parameter Reference)\n"]
|
|
|
+ lines.append("调用工具时请严格使用以下参数名:\n")
|
|
|
+
|
|
|
+ for name in tool_names:
|
|
|
+ if name == "terminate":
|
|
|
+ lines.append(f'- **terminate**: `{{"action":"terminate","input":{{"summary":"结果摘要"}}}}`')
|
|
|
+ continue
|
|
|
+ tool = BUILTIN_TOOLS.get(name)
|
|
|
+ if not tool:
|
|
|
+ continue
|
|
|
+ schema_cls = getattr(tool, 'schema', None)
|
|
|
+ if not schema_cls:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ sig = inspect.signature(schema_cls.__init__)
|
|
|
+ params = []
|
|
|
+ for pname, param in sig.parameters.items():
|
|
|
+ if pname == 'self':
|
|
|
+ continue
|
|
|
+ if param.default is inspect.Parameter.empty:
|
|
|
+ params.append(f'"{pname}": ...') # required
|
|
|
+ else:
|
|
|
+ params.append(f'"{pname}": {json.dumps(param.default)}')
|
|
|
+ param_str = ", ".join(params)
|
|
|
+ lines.append(f'- **{name}**: `{{"action":"{name}","input":{{{param_str}}}}}`')
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+
|
|
|
+ return "\n".join(lines) if len(lines) > 2 else ""
|
|
|
|
|
|
|
|
|
def _compile_react(cfg: Dict, overrides: Dict) -> Term:
|
|
|
@@ -320,7 +584,13 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
|
|
|
- Early termination on implicit signals
|
|
|
- Tool call caching
|
|
|
"""
|
|
|
- think = _compile_lam(cfg, "think")
|
|
|
+ # P1: Inject tool parameter docs into system prompt
|
|
|
+ tool_docs = _generate_tool_schema_docs(cfg)
|
|
|
+ if tool_docs:
|
|
|
+ cfg = dict(cfg) # shallow copy to avoid mutating original
|
|
|
+ cfg["systemPrompt"] = cfg.get("systemPrompt", "") + tool_docs
|
|
|
+
|
|
|
+ think = _compile_lam(cfg, "think", overrides=overrides)
|
|
|
tools = _compile_tools(cfg, overrides)
|
|
|
|
|
|
# S05: Enforce mcp.policy.mode at runtime
|
|
|
@@ -339,87 +609,179 @@ def _compile_react(cfg: Dict, overrides: Dict) -> Term:
|
|
|
verbose = react_cfg.get("verbose", False)
|
|
|
agent_name = cfg.get("name", cfg.get("agentId", "agent"))
|
|
|
|
|
|
- # Shared context for the entire react loop (P0 fix)
|
|
|
+ # Streaming callback (injected via overrides["on_step"])
|
|
|
+ _on_step = overrides.get("on_step")
|
|
|
+
|
|
|
+ # Shared context for the entire react loop
|
|
|
_shared_ctx = Context()
|
|
|
_step_counter = [0]
|
|
|
+ _tool_log = [] # Full tool execution log
|
|
|
+ _user_input = [None] # Original user input (captured on first step)
|
|
|
+ _last_observation = [None] # Latest tool observation for session-resume mode
|
|
|
+
|
|
|
+ # Detect if ClaudeLam supports session persistence (--resume)
|
|
|
+ _has_session = hasattr(think, '_session_id')
|
|
|
|
|
|
def react_step(state):
|
|
|
"""
|
|
|
- One step of ReAct: think -> extract tool -> execute -> format state
|
|
|
- v2: with context passing, state compression, early stop, caching
|
|
|
+ One step of ReAct: think -> extract tool -> execute -> observe.
|
|
|
+
|
|
|
+ With session persistence (ClaudeLam --resume):
|
|
|
+ - Step 0: pass full user input (creates session)
|
|
|
+ - Step N: pass only latest tool observation (Claude remembers history)
|
|
|
+ Without session persistence (standard Lam):
|
|
|
+ - Every step: pass full compressed state (backward compatible)
|
|
|
"""
|
|
|
ctx = _shared_ctx
|
|
|
step = _step_counter[0]
|
|
|
_step_counter[0] += 1
|
|
|
|
|
|
- # Phase 1: Think (beta-reduction)
|
|
|
+ # Capture original user input on first step
|
|
|
+ if step == 0:
|
|
|
+ _user_input[0] = str(state)
|
|
|
+
|
|
|
+ # ── Phase 1: Think (beta-reduction) ──
|
|
|
t0 = time.time()
|
|
|
- thought = think.apply(str(state), ctx)
|
|
|
+
|
|
|
+ if _has_session and step > 0 and _last_observation[0] is not None:
|
|
|
+ # Session mode: only pass latest observation (Claude has full memory)
|
|
|
+ obs = _last_observation[0]
|
|
|
+ remaining = max_steps - step
|
|
|
+ llm_input = (
|
|
|
+ f"[工具执行结果]\n{obs}\n\n"
|
|
|
+ f"[步骤 {step+1}/{max_steps},剩余 {remaining} 步]\n"
|
|
|
+ f"请基于结果决定下一步。输出一个JSON工具调用。\n"
|
|
|
+ f"注意:工具名是 ReadFile/WriteFile/EditFile/Bash/ListFiles(不是 Read/Write/Edit)。"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ # First step or stateless mode: pass full state
|
|
|
+ llm_input = str(state)
|
|
|
+
|
|
|
+ thought = think.apply(llm_input, ctx)
|
|
|
think_ms = (time.time() - t0) * 1000
|
|
|
|
|
|
if verbose:
|
|
|
print(f" B[{step}] think ({think_ms:.0f}ms): {str(thought)[:80]}...")
|
|
|
|
|
|
- # Phase 1.5: Early termination check (P1)
|
|
|
- if _check_implicit_terminate(str(thought)):
|
|
|
- # Check if there's no explicit tool call — if so, terminate
|
|
|
- selected_tool, _ = _extract_tool_call(str(thought), tools)
|
|
|
- if selected_tool is None or selected_tool._name == "terminate":
|
|
|
- if verbose:
|
|
|
- print(f" B[{step}] terminate (implicit signal detected)")
|
|
|
- return str(thought)
|
|
|
+ # Streaming: emit think event
|
|
|
+ if _on_step:
|
|
|
+ from lambdagent.agentruntime.react_engine import StepEvent, STEP_THINK
|
|
|
+ try:
|
|
|
+ _on_step(StepEvent(type=STEP_THINK, step=step, content=str(thought), duration_ms=think_ms))
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
|
|
|
- # Phase 2: Tool Selection (Route / CASE)
|
|
|
+ # ── Phase 2: Extract tool call ──
|
|
|
selected_tool, tool_input = _extract_tool_call(str(thought), tools)
|
|
|
|
|
|
- # Phase 3: Base Case Check
|
|
|
+ # ── Phase 3: Termination check ──
|
|
|
if selected_tool is None or selected_tool._name == "terminate":
|
|
|
+ # Verify completion against actual tool log
|
|
|
+ if _tool_log and step < max_steps - 2:
|
|
|
+ user_lower = _user_input[0].lower() if _user_input[0] else ""
|
|
|
+ all_obs = " ".join(e["observation"] for e in _tool_log)
|
|
|
+ all_inputs = " ".join(e["input"] for e in _tool_log)
|
|
|
+ all_tools = [e["tool"] for e in _tool_log]
|
|
|
+
|
|
|
+ wants_code = any(kw in user_lower for kw in ["补充", "实现", "完成", "修改", "写", "代码", "implement", "fix", "write", "code"])
|
|
|
+ wants_test = any(kw in user_lower for kw in ["测试", "test"])
|
|
|
+ wants_commit = any(kw in user_lower for kw in ["提交", "推送", "commit", "push"])
|
|
|
+
|
|
|
+ wrote_code = ("WriteFile" in all_tools or "Bash" in all_tools) and "[OK]" in all_obs
|
|
|
+ ran_tests = ("mvn test" in all_inputs or "pytest" in all_inputs or "npm test" in all_inputs) and ("BUILD SUCCESS" in all_obs or " passed" in all_obs.lower() or "Tests run:" in all_obs)
|
|
|
+ did_commit = "git commit" in all_inputs and ("[master" in all_obs or "[main" in all_obs or "create mode" in all_obs)
|
|
|
+ did_push = "git push" in all_inputs
|
|
|
+
|
|
|
+ missing = []
|
|
|
+ if wants_code and not wrote_code:
|
|
|
+ missing.append("写入代码 (WriteFile 或 Bash)")
|
|
|
+ if wants_test and not ran_tests:
|
|
|
+ missing.append("运行测试 (mvn test / pytest)")
|
|
|
+ if wants_commit and not did_commit:
|
|
|
+ missing.append("git add && git commit")
|
|
|
+ if wants_commit and not did_push:
|
|
|
+ missing.append("git push")
|
|
|
+
|
|
|
+ if missing:
|
|
|
+ # Push back — force LLM to continue
|
|
|
+ _last_observation[0] = (
|
|
|
+ f"[SYSTEM] 任务未完成。以下操作没有实际执行:\n"
|
|
|
+ + "\n".join(f" - {m}" for m in missing)
|
|
|
+ + "\n请立即用工具执行这些操作。"
|
|
|
+ )
|
|
|
+ return f"{_user_input[0]}\n[Step {step+1}] pending"
|
|
|
+
|
|
|
if verbose:
|
|
|
print(f" B[{step}] terminate (base case)")
|
|
|
+ if _on_step:
|
|
|
+ from lambdagent.agentruntime.react_engine import StepEvent, STEP_ANSWER
|
|
|
+ try:
|
|
|
+ _on_step(StepEvent(type=STEP_ANSWER, step=step, content=str(thought)))
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
return str(thought)
|
|
|
|
|
|
- # Phase 4: Tool Execution with caching (P2)
|
|
|
+ # ── Phase 4: Tool Execution ──
|
|
|
tool_name = selected_tool._name
|
|
|
- cache_key = f"{tool_name}:{hashlib.md5(str(tool_input).encode()).hexdigest()[:12]}"
|
|
|
- cached = _tool_cache.get(cache_key)
|
|
|
|
|
|
- if cached is not None:
|
|
|
- observation = cached
|
|
|
- if verbose:
|
|
|
- print(f" B[{step}] Tool:{tool_name} (CACHED): {observation[:60]}...")
|
|
|
- else:
|
|
|
- t0 = time.time()
|
|
|
+ # Streaming: emit tool_call event
|
|
|
+ if _on_step:
|
|
|
+ from lambdagent.agentruntime.react_engine import StepEvent, STEP_TOOL_CALL
|
|
|
try:
|
|
|
- observation = str(_timeout_call(selected_tool, tool_input or str(thought), tool_timeout))
|
|
|
- except Exception as e:
|
|
|
- observation = f"[TOOL_ERROR] {e}"
|
|
|
- tool_ms = (time.time() - t0) * 1000
|
|
|
- _tool_cache.put(cache_key, observation)
|
|
|
+ _on_step(StepEvent(type=STEP_TOOL_CALL, step=step, content=str(tool_input)[:500], tool=tool_name))
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
|
|
|
- if verbose:
|
|
|
- print(f" B[{step}] Tool:{tool_name} ({tool_ms:.0f}ms): {observation[:60]}...")
|
|
|
+ t0 = time.time()
|
|
|
+ try:
|
|
|
+ # Serialize dict to JSON string for consistent _parse_input handling
|
|
|
+ if isinstance(tool_input, dict):
|
|
|
+ tool_input_val = json.dumps(tool_input, ensure_ascii=False)
|
|
|
+ else:
|
|
|
+ tool_input_val = tool_input or str(thought)
|
|
|
+ observation = str(_timeout_call(selected_tool, tool_input_val, tool_timeout))
|
|
|
+ except Exception as e:
|
|
|
+ observation = f"[TOOL_ERROR] {e}"
|
|
|
+ tool_ms = (time.time() - t0) * 1000
|
|
|
+
|
|
|
+ if verbose:
|
|
|
+ print(f" B[{step}] Tool:{tool_name} ({tool_ms:.0f}ms): {observation[:60]}...")
|
|
|
+
|
|
|
+ # Record to tool log
|
|
|
+ _tool_log.append({"tool": tool_name, "input": str(tool_input_val)[:200], "observation": observation[:500]})
|
|
|
+
|
|
|
+ # Streaming: emit tool_result event
|
|
|
+ if _on_step:
|
|
|
+ from lambdagent.agentruntime.react_engine import StepEvent, STEP_TOOL_RESULT
|
|
|
+ try:
|
|
|
+ _on_step(StepEvent(type=STEP_TOOL_RESULT, step=step, content=observation[:1000], tool=tool_name))
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
|
|
|
# Log to context
|
|
|
ctx.log(f"Tool:{tool_name}", "", str(tool_input)[:200], observation[:200],
|
|
|
- think_ms, think.model)
|
|
|
+ think_ms, think.model if hasattr(think, 'model') else "")
|
|
|
|
|
|
- # Phase 5: State Update with compression (P0)
|
|
|
- if observation_enabled:
|
|
|
- return _compress_state(str(state), str(thought), tool_name, observation)
|
|
|
+ # ── Phase 5: Prepare next state ──
|
|
|
+ # Store observation for session-resume mode
|
|
|
+ _last_observation[0] = f"Tool: {tool_name}\nResult:\n{observation}"
|
|
|
+
|
|
|
+ # Return state with step marker (for stop_condition detection)
|
|
|
+ if _has_session:
|
|
|
+ # Session mode: minimal state (Claude has full memory via --resume)
|
|
|
+ return f"{_user_input[0]}\n[Step {step+1}] {tool_name} done"
|
|
|
else:
|
|
|
- return str(thought)
|
|
|
+ # Stateless mode: full compressed state (backward compatible)
|
|
|
+ return _compress_state(str(state), str(thought), tool_name, observation)
|
|
|
|
|
|
body = Tool(f"{agent_name}.react_step", react_step)
|
|
|
|
|
|
- # Early stop condition: also check implicit termination
|
|
|
def stop_condition(result, step):
|
|
|
if step >= max_steps - 1:
|
|
|
return True
|
|
|
- # If the last result doesn't contain step markers, it's a final answer
|
|
|
- if isinstance(result, str) and "[Step " not in result[-200:]:
|
|
|
- # Result is a direct answer, not a state string
|
|
|
- if _check_implicit_terminate(result):
|
|
|
- return True
|
|
|
+ # No [Step marker = no tool was called = final answer
|
|
|
+ if isinstance(result, str) and "[Step " not in result:
|
|
|
+ return True
|
|
|
return False
|
|
|
|
|
|
return Loop(
|
|
|
@@ -802,15 +1164,9 @@ def _extract_tool_call(thought: str, tools: Dict[str, Tool]):
|
|
|
inp = {"query": inp}
|
|
|
return tools[tool_name], inp
|
|
|
|
|
|
- # Keyword match (fallback) — skip terminate for keyword match
|
|
|
- thought_lower = thought.lower()
|
|
|
- for tool_name, tool in tools.items():
|
|
|
- if tool_name == "terminate":
|
|
|
- continue # Don't match terminate by keyword
|
|
|
- if tool_name.lower() in thought_lower:
|
|
|
- return tool, thought
|
|
|
-
|
|
|
- # No tool call detected -> implicit terminate
|
|
|
+ # No structured tool call detected (JSON/XML) -> no tool invocation.
|
|
|
+ # Keyword matching removed: too many false positives when LLM mentions
|
|
|
+ # tool names in natural language (e.g., "ReadFile 工具可以读取文件").
|
|
|
return None, None
|
|
|
|
|
|
|