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.
- agentlisp-2.0.0.dist-info/METADATA +482 -0
- agentlisp-2.0.0.dist-info/RECORD +36 -0
- agentlisp-2.0.0.dist-info/WHEEL +4 -0
- agentlisp-2.0.0.dist-info/entry_points.txt +3 -0
- agentlisp_runtime/__init__.py +29 -0
- agentlisp_runtime/cli.py +145 -0
- agentlisp_runtime/engine.py +253 -0
- agentlisp_runtime/loader.py +126 -0
- agentlisp_runtime/models.py +129 -0
- host/__init__.py +5 -0
- host/gateway.py +128 -0
- host/gateway_sse.py +788 -0
- host/sandbox.py +143 -0
- host/sandbox_e2b.py +173 -0
- host/tests/test_infra_docker_e2e.py +247 -0
- host/workflow.py +803 -0
- host/workflow_temporal.py +89 -0
- runtime/__init__.py +29 -0
- runtime/base_agent_harness.py +164 -0
- runtime/base_harness.py +359 -0
- runtime/base_harness_v2.py +1379 -0
- runtime/checker.py +654 -0
- runtime/checkpoint.py +98 -0
- runtime/errors.py +37 -0
- runtime/llm_client.py +644 -0
- runtime/mcp_client.py +86 -0
- runtime/memory_fs.py +90 -0
- runtime/otel_tracer.py +124 -0
- runtime/status_bar.py +86 -0
- runtime/tests/test_ac1_rackunit_18cases_roundtrip.py +255 -0
- runtime/tests/test_cli_if_cli_1_exit_encoding.py +309 -0
- runtime/tests/test_harness_v2.py +2030 -0
- runtime/tests/test_sideeffect_builtin_tools_racket_and_python_ssot_8_items_bitwise_equal.py +132 -0
- runtime/tests/test_srs_acceptance.py +1369 -0
- runtime/tests/test_t2_bench_dataset.py +86 -0
- runtime/tests/test_v2_smoke.py +139 -0
|
@@ -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 运行成功!")
|
runtime/base_harness.py
ADDED
|
@@ -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))
|