agentlisp 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ """
2
+ AgentLisp v2.0 Temporal 长流程与人在回路 (HITL) 引擎 (host/workflow_temporal.py)
3
+ 符合 IF-TEMPORAL-1 规约:
4
+ 利用 Temporal 实现长流程挂起落盘与人在回路 Signal 唤醒审批机制。
5
+ """
6
+
7
+ import logging
8
+ from datetime import timedelta
9
+ from typing import Any
10
+
11
+ try:
12
+ from temporalio import activity, workflow
13
+ from temporalio.client import Client
14
+
15
+ TEMPORAL_AVAILABLE = True
16
+ except ImportError:
17
+ TEMPORAL_AVAILABLE = False
18
+
19
+ logger = logging.getLogger("AgentLisp.TemporalEngine")
20
+
21
+
22
+ class AgentLispTemporalEngine:
23
+ def __init__(self, temporal_host: str = "localhost:7233"):
24
+ self.temporal_host = temporal_host
25
+ self.client: Any | None = None
26
+
27
+ async def connect(self):
28
+ if not TEMPORAL_AVAILABLE:
29
+ logger.info("ℹ️ Temporal SDK 未安装,将使用 DirectRunner 本地非持久化运行。")
30
+ return
31
+ try:
32
+ self.client = await Client.connect(self.temporal_host)
33
+ logger.info(f"✅ 已成功连接 Temporal 长流程服务端 -> {self.temporal_host}")
34
+ except Exception as e:
35
+ logger.warning(f"⚠️ 无法连接 Temporal 服务端 ({e}),降级为本地内存调度。")
36
+
37
+
38
+ if TEMPORAL_AVAILABLE:
39
+
40
+ @activity.defn(name="agentlisp_execute_tool_activity")
41
+ async def execute_tool_activity(params: dict[str, Any]) -> dict[str, Any]:
42
+ """Temporal Activity:执行受 Harness 门控保护的工具动作"""
43
+ tool_name = params.get("tool_name")
44
+ cmd = params.get("args", {}).get("command", "")
45
+ logger.info(f"⚡ [Temporal Activity] 正在执行工具: {tool_name} ({cmd})")
46
+ return {"exit_code": 0, "stdout": f"Executed {cmd} successfully under Temporal Activity."}
47
+
48
+ @workflow.defn(name="AgentLispHITLWorkflow")
49
+ class AgentLispHITLWorkflow:
50
+ def __init__(self):
51
+ self.approval_signal_received = False
52
+ self.approval_decision = "PENDING"
53
+
54
+ @workflow.signal(name="approve_tool_execution")
55
+ def receive_approval_signal(self, decision: str):
56
+ """接收运维/人类审批人员发来的 Approve / Reject 信号"""
57
+ self.approval_signal_received = True
58
+ self.approval_decision = decision
59
+
60
+ @workflow.run
61
+ async def run(self, agent_request: dict[str, Any]) -> dict[str, Any]:
62
+ tool_name = agent_request.get("tool_name", "git-push")
63
+ requires_approval = agent_request.get("requires_approval", True)
64
+
65
+ if requires_approval:
66
+ logger.info(
67
+ f"⏸ [Temporal HITL 挂起] 工具 '{tool_name}' 需要人类审批,等待 Signal 唤醒..."
68
+ )
69
+ # 栈帧挂起落盘,绝不占用物理内存和线程
70
+ await workflow.wait_condition(lambda: self.approval_signal_received)
71
+
72
+ if self.approval_decision != "APPROVED":
73
+ logger.warning(f"❌ [Temporal HITL 拒绝] 审批结果: {self.approval_decision}")
74
+ return {"status": "rejected", "reason": "Human reviewer rejected execution."}
75
+
76
+ logger.info("▶ [Temporal HITL 恢复] 审批通过,继续执行 Activity...")
77
+ # temporalio 1.x 要求所有 timeout 必须是 datetime.timedelta(不能是 float/None 且不能写成 deadline float),
78
+ # 否则 protobuf well_known_types.FromTimedelta 会报 AttributeError float has no attribute 'seconds'
79
+ res = await workflow.execute_activity(
80
+ execute_tool_activity,
81
+ agent_request,
82
+ start_to_close_timeout=timedelta(seconds=60),
83
+ )
84
+ return {"status": "success", "result": res}
85
+
86
+
87
+ if __name__ == "__main__":
88
+ print("=== 测试 AgentLisp Temporal 模版加载 ===")
89
+ print(f"Temporal SDK 可用性: {TEMPORAL_AVAILABLE}")
runtime/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Runtime public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base_harness import (
6
+ BaseHarness,
7
+ ExecutionTrace,
8
+ ReActTurn,
9
+ )
10
+ from .errors import (
11
+ AgentLispError,
12
+ CheckpointError,
13
+ FeatureNotInstalledError,
14
+ HarnessError,
15
+ ToolNotFoundError,
16
+ )
17
+
18
+ __version__ = "2.0.0"
19
+ __all__ = [
20
+ "AgentLispError",
21
+ "BaseHarness",
22
+ "CheckpointError",
23
+ "ExecutionTrace",
24
+ "FeatureNotInstalledError",
25
+ "HarnessError",
26
+ "ReActTurn",
27
+ "ToolNotFoundError",
28
+ "__version__",
29
+ ]
@@ -0,0 +1,164 @@
1
+ """
2
+ AgentLisp v2.0 核心运行时基类 (runtime/base_agent_harness.py)
3
+ 固化架构规范:
4
+ 1. KV Cache 严格前缀对齐 (Static System Prompt -> Static Tools -> Trajectory -> Status Bar)
5
+ 2. Harness 三重控制流管道 (Constrain -> Execute -> Verify -> Correct)
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Any
11
+
12
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - [%(levelname)s] - %(message)s")
13
+
14
+
15
+ class BaseAgentHarness:
16
+ def __init__(
17
+ self,
18
+ model_config: dict[str, Any],
19
+ context_config: dict[str, Any],
20
+ tools_config: dict[str, Any],
21
+ harness_config: dict[str, Any],
22
+ ):
23
+ self.model_config = model_config
24
+ self.context_config = context_config
25
+ self.tools_config = tools_config
26
+ self.harness_config = harness_config
27
+
28
+ # 运行时状态
29
+ self.trajectory: list[dict[str, Any]] = []
30
+ self.step_count: int = 0
31
+ self.is_terminated: bool = False
32
+
33
+ # ------------------------------------------------------------------
34
+ # 1. KV Cache 对齐的上下文组装器 (Static -> Dynamic -> Trailing Hook)
35
+ # ------------------------------------------------------------------
36
+ def build_context(self) -> list[dict[str, Any]]:
37
+ """
38
+ 严格按照 AgentLisp 规约组装 Context:
39
+ [1. System Prompt (Static)] -> [2. Tool Schemas (Static)]
40
+ -> [3. History Trajectory (Dynamic)] -> [4. Status Bar (Trailing Hook)]
41
+ """
42
+ messages = []
43
+
44
+ # (1) 静态 System Prompt
45
+ sys_prompt = self.model_config.get("system_prompt", "You are a helpful Agent.")
46
+ messages.append({"role": "system", "content": sys_prompt})
47
+
48
+ # (2) 静态工具声明 (有助于 LLM 供应商进行 Prefix Caching)
49
+ if "tools_schema" in self.tools_config:
50
+ messages.append(
51
+ {
52
+ "role": "system",
53
+ "content": f"<tools_definition>\n{self.tools_config['tools_schema']}\n</tools_definition>",
54
+ }
55
+ )
56
+
57
+ # (3) 动态历史轨迹 (Trajectory)
58
+ messages.extend(self.trajectory)
59
+
60
+ # (4) 尾部 Status Bar 挂钩 (防止模型长对话迷失)
61
+ status_items = self.context_config.get("status_bar", {})
62
+ if status_items.get("step_count", True):
63
+ status_bar_str = (
64
+ f"<agent_status>Step: {self.step_count} | Status: Active</agent_status>"
65
+ )
66
+ messages.append({"role": "user", "content": status_bar_str})
67
+
68
+ return messages
69
+
70
+ # ------------------------------------------------------------------
71
+ # 2. Harness 三重安全与控制流管道
72
+ # ------------------------------------------------------------------
73
+ def constrain(self, tool_call: dict[str, Any]) -> tuple[bool, str]:
74
+ """【护栏 1:约束 (Constrain)】负面清单与工作区安全锁"""
75
+ cmd = tool_call.get("args", {}).get("command", "")
76
+ forbidden_list = self.harness_config.get("constrain", {}).get("forbidden_commands", [])
77
+
78
+ for forbidden in forbidden_list:
79
+ if forbidden in cmd:
80
+ logging.warning(f"❌ [Constrain 拦截] 触发禁用命令: {forbidden}")
81
+ return False, f"Harness Blocked: Execution of '{forbidden}' is strictly forbidden."
82
+
83
+ return True, "OK"
84
+
85
+ def verify(self, observation: dict[str, Any]) -> tuple[bool, str]:
86
+ """【护栏 2:验证 (Verify)】结果静态/动态断言"""
87
+ # 如果观察结果中存在语法/Linter 错误
88
+ if observation.get("exit_code", 0) != 0:
89
+ err_msg = observation.get("stderr", "Execution failed")
90
+ logging.warning(f"⚠️️ [Verify 失败] 输出断言不通过: {err_msg}")
91
+ return False, err_msg
92
+
93
+ return True, "Verified"
94
+
95
+ async def correct(
96
+ self, tool_call: dict[str, Any], error_msg: str, retries: int
97
+ ) -> dict[str, Any]:
98
+ """【护栏 3:纠正 (Correct)】局部静默重试与降级"""
99
+ max_retries = self.harness_config.get("correct", {}).get("max_retries", 3)
100
+ logging.info(f"🔄 [Correct 自动纠错] 第 {retries}/{max_retries} 次重试...")
101
+
102
+ if retries >= max_retries:
103
+ on_failure = self.harness_config.get("correct", {}).get("on_failure", "ask_human")
104
+ logging.error(f"🚨 [Correct 熔断] 已达最大重试上限,触发终态策略: {on_failure}")
105
+ return {"status": "failed", "action": on_failure, "error": error_msg}
106
+
107
+ # 内部静默重试逻辑(可返回反馈提示给模型)
108
+ return {
109
+ "status": "retry",
110
+ "feedback": f"Previous execution failed with: {error_msg}. Please fix your parameters.",
111
+ }
112
+
113
+ # ------------------------------------------------------------------
114
+ # 3. 核心 ReAct 步骤驱动循环
115
+ # ------------------------------------------------------------------
116
+ async def step(self, user_input: str | None = None) -> dict[str, Any]:
117
+ self.step_count += 1
118
+ if user_input:
119
+ self.trajectory.append({"role": "user", "content": user_input})
120
+
121
+ context = self.build_context()
122
+ logging.info(f"🚀 [Step {self.step_count}] 上下文组装完毕,消息数: {len(context)}")
123
+
124
+ # 模拟模型决策与工具调用(实际运行时替换为真实的 LLM API 调用)
125
+ mock_tool_call = {"tool_name": "bash", "args": {"command": "ls -la"}}
126
+
127
+ # 运行 Harness 管道
128
+ allowed, reason = self.constrain(mock_tool_call)
129
+ if not allowed:
130
+ self.trajectory.append({"role": "tool", "content": reason})
131
+ return {"status": "blocked", "reason": reason}
132
+
133
+ # 模拟执行工具
134
+ mock_observation = {"exit_code": 0, "stdout": "file1.py\nfile2.py", "stderr": ""}
135
+
136
+ passed, verify_msg = self.verify(mock_observation)
137
+ if not passed:
138
+ correction = await self.correct(mock_tool_call, verify_msg, retries=1)
139
+ return correction
140
+
141
+ self.trajectory.append({"role": "tool", "content": mock_observation["stdout"]})
142
+ return {"status": "success", "output": mock_observation["stdout"]}
143
+
144
+
145
+ # ----------------------------------------------------------------------
146
+ # 本地验证:单文件可直接运行测试
147
+ # ----------------------------------------------------------------------
148
+ if __name__ == "__main__":
149
+ print("=== 测试 AgentLisp BaseAgentHarness 运行时 ===")
150
+
151
+ # 初始化配置
152
+ agent_harness = BaseAgentHarness(
153
+ model_config={"system_prompt": "你是一个自动修复 Bug 的 Agent。"},
154
+ context_config={"status_bar": {"step_count": True}},
155
+ tools_config={"tools_schema": "Tool: bash(command: str)"},
156
+ harness_config={
157
+ "constrain": {"forbidden_commands": ["rm -rf"]},
158
+ "correct": {"max_retries": 3, "on_failure": "ask_human"},
159
+ },
160
+ )
161
+
162
+ # 运行一步测试
163
+ asyncio.run(agent_harness.step("请检查当前目录下的文件"))
164
+ print("✅ BaseAgentHarness 运行成功!")
@@ -0,0 +1,359 @@
1
+ """AgentLisp v2 BaseHarness ReAct loop & pipeline skeleton."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from collections.abc import AsyncIterable, Callable
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ import structlog
13
+ from pydantic import BaseModel, Field
14
+
15
+ from .errors import HarnessError, ToolNotFoundError
16
+
17
+ log = structlog.get_logger()
18
+
19
+
20
+ @dataclass
21
+ class ReActTurn:
22
+ index: int
23
+ thought: str = ""
24
+ action_name: str = ""
25
+ action_input: dict[str, Any] = field(default_factory=dict)
26
+ observation: Any = None
27
+ answer: str = ""
28
+ started_at: float = field(default_factory=time.perf_counter)
29
+ finished_at: float | None = None
30
+
31
+ def finish(self) -> None:
32
+ self.finished_at = time.perf_counter()
33
+
34
+ @property
35
+ def duration_ms(self) -> float:
36
+ end = self.finished_at if self.finished_at is not None else time.perf_counter()
37
+ return (end - self.started_at) * 1000.0
38
+
39
+
40
+ class ExecutionTrace(BaseModel):
41
+ run_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
42
+ agent_name: str = ""
43
+ turns: list[dict[str, Any]] = Field(default_factory=list)
44
+ final_answer: str = ""
45
+ status: str = "pending"
46
+ started_at: float = Field(default_factory=time.perf_counter)
47
+ finished_at: float | None = None
48
+ error: str | None = None
49
+
50
+ @property
51
+ def duration_ms(self) -> float:
52
+ end = self.finished_at if self.finished_at is not None else time.perf_counter()
53
+ return (end - self.started_at) * 1000.0
54
+
55
+
56
+ class BaseHarness:
57
+ """ReAct 循环 + Harness 管道基类。
58
+
59
+ 设计目标:
60
+ 1. 完全无外部依赖也能跑(通过 MockLLM / NullTool / MemoryCheckpoint);
61
+ 2. Compiler 输出直接继承此类;
62
+ 3. 支持 run / run_async / stream_async 三种调用方式;
63
+ 4. 每次调用生成一条 ExecutionTrace(证据链)。
64
+ """
65
+
66
+ agent_name: str = ""
67
+
68
+ def __init__(
69
+ self,
70
+ agent_cfg: dict[str, Any] | None = None,
71
+ llm_client: Any | None = None,
72
+ tool_registry: Any | None = None,
73
+ checkpoint_store: Any | None = None,
74
+ memory_fs: Any | None = None,
75
+ status_bar: Any | None = None,
76
+ max_turns: int = 30,
77
+ react_mode: bool = True,
78
+ ) -> None:
79
+ self.cfg = agent_cfg or {}
80
+ self.agent_name = self.agent_name or str(self.cfg.get("name", "unnamed-agent"))
81
+ self.llm = llm_client or _NullLLM()
82
+ self.tools = tool_registry or _NullToolRegistry()
83
+ self.checkpoint = checkpoint_store or _MemoryCheckpoint()
84
+ self.memory = memory_fs or _NullMemoryFS()
85
+ self.status_bar = status_bar or _NullStatusBar()
86
+ self.max_turns = int(max_turns)
87
+ self.react_mode = bool(react_mode)
88
+ self._hooks: dict[str, list[Callable[..., Any]]] = {}
89
+
90
+ # ------------------------------------------------------------------ hooks
91
+ def on(self, event: str, cb: Callable[..., Any]) -> None:
92
+ self._hooks.setdefault(event, []).append(cb)
93
+
94
+ def _emit(self, event: str, payload: dict[str, Any]) -> None:
95
+ for cb in self._hooks.get(event, []):
96
+ try:
97
+ cb(payload)
98
+ except Exception:
99
+ log.exception("hook.failed", event=event)
100
+
101
+ # ------------------------------------------------------------- public API
102
+ def run(self, inputs: dict[str, Any] | None = None, **kwargs: Any) -> ExecutionTrace:
103
+ return asyncio.run(self.run_async(inputs=inputs, **kwargs))
104
+
105
+ async def run_async(
106
+ self,
107
+ inputs: dict[str, Any] | None = None,
108
+ workflow: str | None = None,
109
+ ) -> ExecutionTrace:
110
+ trace = ExecutionTrace(agent_name=self.agent_name)
111
+ try:
112
+ await self.status_bar.on_start(trace, inputs or {})
113
+ self._emit("start", {"run_id": trace.run_id, "inputs": inputs})
114
+ await self.checkpoint.save(trace.run_id, {"stage": "start", "inputs": inputs})
115
+
116
+ if workflow and (engine := self._legacy_engine()):
117
+ from agentlisp_runtime.models import ExecutionContext # noqa: F401
118
+
119
+ result = await engine.run_workflow(
120
+ workflow_name=workflow,
121
+ )
122
+ for step in result.steps:
123
+ t = ReActTurn(
124
+ index=len(trace.turns),
125
+ action_name=f"step:{step.step_id}",
126
+ action_input={"status": step.status.value},
127
+ observation=step.output,
128
+ )
129
+ t.finish()
130
+ trace.turns.append(_turn_to_dict(t))
131
+ trace.final_answer = _jsonable_snapshot(result)
132
+ trace.status = result.status.value
133
+ else:
134
+ await self._react_loop(trace, inputs or {})
135
+
136
+ trace.status = "success" if trace.status == "pending" else trace.status
137
+ self._emit("done", {"run_id": trace.run_id, "answer": trace.final_answer})
138
+ await self.status_bar.on_done(trace)
139
+ except Exception as exc:
140
+ trace.status = "failed"
141
+ trace.error = str(exc)
142
+ log.exception("harness.failed", run_id=trace.run_id, error=str(exc))
143
+ await self.status_bar.on_error(trace, exc)
144
+ self._emit("error", {"run_id": trace.run_id, "error": str(exc)})
145
+ raise HarnessError(f"run {trace.run_id} failed: {exc}") from exc
146
+ finally:
147
+ trace.finished_at = time.perf_counter()
148
+ await self.checkpoint.save(trace.run_id, {"trace": trace.model_dump(mode="json")})
149
+ return trace
150
+
151
+ async def stream_async(
152
+ self, inputs: dict[str, Any] | None = None, **_kw: Any
153
+ ) -> AsyncIterable[dict[str, Any]]:
154
+ q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
155
+
156
+ def _enqueue(payload: dict[str, Any]) -> None:
157
+ q.put_nowait(payload)
158
+
159
+ self.on("start", lambda p: _enqueue({"event": "start", **p}))
160
+ self.on("turn", lambda p: _enqueue({"event": "turn", **p}))
161
+ self.on("done", lambda p: _enqueue({"event": "done", **p}))
162
+ self.on("error", lambda p: _enqueue({"event": "error", **p}))
163
+
164
+ task = asyncio.create_task(self.run_async(inputs=inputs))
165
+
166
+ while not task.done() or not q.empty():
167
+ try:
168
+ item = await asyncio.wait_for(q.get(), timeout=0.1)
169
+ yield item
170
+ except TimeoutError:
171
+ continue
172
+ # drain remaining
173
+ while not q.empty():
174
+ yield q.get_nowait()
175
+ await task
176
+
177
+ # ------------------------------------------------------------- ReAct core
178
+ async def _react_loop(self, trace: ExecutionTrace, inputs: dict[str, Any]) -> None:
179
+ prompt_payload = {
180
+ "agent": self.agent_name,
181
+ "purpose": self.cfg.get("purpose", ""),
182
+ "tools": self.cfg.get("tools", []),
183
+ "workflows": self.cfg.get("workflows", []),
184
+ "inputs": inputs,
185
+ }
186
+ history: list[dict[str, Any]] = []
187
+
188
+ for idx in range(self.max_turns):
189
+ turn = ReActTurn(index=idx)
190
+ self._emit("turn", {"index": idx, "stage": "think"})
191
+ try:
192
+ response = await self.llm.next(prompt_payload, history, trace)
193
+ except Exception as exc:
194
+ raise HarnessError(f"LLM call failed at turn {idx}: {exc}") from exc
195
+
196
+ structured = self._coerce_llm_response(response)
197
+ turn.thought = structured.get("thought", "")
198
+ turn.action_name = structured.get("action", "")
199
+ turn.action_input = structured.get("action_input", {}) or {}
200
+
201
+ if structured.get("answer") or structured.get("final_answer"):
202
+ turn.answer = structured.get("answer") or structured.get("final_answer", "")
203
+ trace.final_answer = turn.answer
204
+ turn.finish()
205
+ trace.turns.append(_turn_to_dict(turn))
206
+ self._emit("turn", {"index": idx, "stage": "answer", "answer": turn.answer})
207
+ return
208
+
209
+ try:
210
+ obs = await self._dispatch_tool(turn.action_name, turn.action_input)
211
+ except ToolNotFoundError:
212
+ obs = {"error": f"tool not found: {turn.action_name}"}
213
+ turn.observation = obs
214
+ turn.finish()
215
+ history.append({"role": "assistant", **_turn_to_dict(turn)})
216
+ history.append({"role": "observation", "index": idx, "value": _jsonable_snapshot(obs)})
217
+ trace.turns.append(_turn_to_dict(turn))
218
+ await self.checkpoint.save(trace.run_id, {"turns": trace.turns})
219
+ await self.status_bar.on_step(trace, turn)
220
+ self._emit("turn", {"index": idx, "stage": "observed", "observation": obs})
221
+
222
+ raise HarnessError(f"ReAct exceeded max_turns={self.max_turns}")
223
+
224
+ @staticmethod
225
+ def _coerce_llm_response(raw: Any) -> dict[str, Any]:
226
+ if isinstance(raw, dict):
227
+ return raw
228
+ if isinstance(raw, str):
229
+ return {"thought": raw[:500]}
230
+ if hasattr(raw, "model_dump"):
231
+ return dict(raw.model_dump()) # type: ignore[attr-defined]
232
+ return {"thought": str(raw)[:500]}
233
+
234
+ async def _dispatch_tool(self, action_name: str, action_input: dict[str, Any]) -> Any:
235
+ if not action_name:
236
+ return {"skipped": True}
237
+ if hasattr(self.tools, "call"):
238
+ return await self.tools.call(action_name, **action_input)
239
+ if hasattr(self.tools, "get"):
240
+ fn = self.tools.get(action_name)
241
+ if callable(fn):
242
+ if asyncio.iscoroutinefunction(fn):
243
+ return await fn(**action_input)
244
+ return fn(**action_input)
245
+ raise ToolNotFoundError(f"no handler for action: {action_name}")
246
+
247
+ # ----------------------------------------------------------- v0.1 bridge
248
+ def _legacy_engine(self) -> Any | None:
249
+ try:
250
+ from agentlisp_runtime.engine import AgentEngine
251
+ from agentlisp_runtime.loader import AgentLoader
252
+ except Exception:
253
+ return None
254
+ try:
255
+ agent = AgentLoader.from_dict(self.cfg)
256
+ handlers = {
257
+ k: v for k, v in (self.tools.handlers() if hasattr(self.tools, "handlers") else {})
258
+ }
259
+ return AgentEngine(agent, tool_handlers=handlers)
260
+ except Exception:
261
+ return None
262
+
263
+
264
+ def _turn_to_dict(t: ReActTurn) -> dict[str, Any]:
265
+ return {
266
+ "index": t.index,
267
+ "thought": t.thought,
268
+ "action": t.action_name,
269
+ "action_input": t.action_input,
270
+ "observation": _jsonable_snapshot(t.observation),
271
+ "answer": t.answer,
272
+ "duration_ms": round(t.duration_ms, 3),
273
+ }
274
+
275
+
276
+ def _jsonable_snapshot(v: Any) -> Any:
277
+ if isinstance(v, (str, int, float, bool)) or v is None:
278
+ return v
279
+ if isinstance(v, (list, tuple)):
280
+ return [_jsonable_snapshot(x) for x in v]
281
+ if isinstance(v, dict):
282
+ return {str(k): _jsonable_snapshot(val) for k, val in v.items()}
283
+ if hasattr(v, "model_dump"):
284
+ try:
285
+ return v.model_dump(mode="json") # type: ignore[attr-defined]
286
+ except Exception:
287
+ return str(v)
288
+ return str(v)
289
+
290
+
291
+ # ----------------------------------------------------------------- null impls
292
+ class _NullLLM:
293
+ async def next(
294
+ self, prompt: dict[str, Any], history: list[dict[str, Any]], trace: ExecutionTrace
295
+ ) -> dict[str, Any]:
296
+ # 无 LLM 时默认:最多 1 个 answer 回合,终止
297
+ if any(t.get("answer") for t in trace.turns):
298
+ return {"answer": "<done>"}
299
+ return {
300
+ "thought": "MockLLM: no-op (install llm optional group for real LLM)",
301
+ "action": "",
302
+ "action_input": {},
303
+ "answer": f"finished (mock). inputs={prompt.get('inputs')}",
304
+ }
305
+
306
+
307
+ class _NullToolRegistry:
308
+ def __init__(self) -> None:
309
+ self._handlers: dict[str, Callable[..., Any]] = {}
310
+
311
+ def register(self, name: str, fn: Callable[..., Any]) -> None:
312
+ self._handlers[name] = fn
313
+
314
+ def handlers(self) -> dict[str, Callable[..., Any]]:
315
+ return dict(self._handlers)
316
+
317
+ def get(self, name: str) -> Callable[..., Any] | None:
318
+ return self._handlers.get(name)
319
+
320
+ async def call(self, name: str, **kwargs: Any) -> Any:
321
+ fn = self._handlers.get(name)
322
+ if fn is None:
323
+ raise ToolNotFoundError(name)
324
+ if asyncio.iscoroutinefunction(fn):
325
+ return await fn(**kwargs)
326
+ return fn(**kwargs)
327
+
328
+
329
+ class _MemoryCheckpoint:
330
+ def __init__(self) -> None:
331
+ self.store: dict[str, dict[str, Any]] = {}
332
+
333
+ async def save(self, run_id: str, snapshot: dict[str, Any]) -> None:
334
+ self.store.setdefault(run_id, {}).update(snapshot)
335
+
336
+ async def load(self, run_id: str) -> dict[str, Any] | None:
337
+ return self.store.get(run_id)
338
+
339
+
340
+ class _NullMemoryFS:
341
+ async def read(self, path: str) -> str | None:
342
+ return None
343
+
344
+ async def write(self, path: str, content: str) -> None:
345
+ return None
346
+
347
+
348
+ class _NullStatusBar:
349
+ async def on_start(self, trace: ExecutionTrace, inputs: dict[str, Any]) -> None:
350
+ log.debug("status.on_start", run_id=trace.run_id)
351
+
352
+ async def on_step(self, trace: ExecutionTrace, turn: ReActTurn) -> None:
353
+ log.debug("status.on_step", run_id=trace.run_id, turn=turn.index)
354
+
355
+ async def on_done(self, trace: ExecutionTrace) -> None:
356
+ log.debug("status.on_done", run_id=trace.run_id, status=trace.status)
357
+
358
+ async def on_error(self, trace: ExecutionTrace, exc: BaseException) -> None:
359
+ log.debug("status.on_error", run_id=trace.run_id, error=str(exc))