loom-agent 0.0.1__py3-none-any.whl → 0.0.2__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.
Potentially problematic release.
This version of loom-agent might be problematic. Click here for more details.
- loom/builtin/tools/calculator.py +4 -0
- loom/builtin/tools/document_search.py +5 -0
- loom/builtin/tools/glob.py +4 -0
- loom/builtin/tools/grep.py +4 -0
- loom/builtin/tools/http_request.py +5 -0
- loom/builtin/tools/python_repl.py +5 -0
- loom/builtin/tools/read_file.py +4 -0
- loom/builtin/tools/task.py +5 -0
- loom/builtin/tools/web_search.py +4 -0
- loom/builtin/tools/write_file.py +4 -0
- loom/components/agent.py +121 -5
- loom/core/agent_executor.py +505 -320
- loom/core/compression_manager.py +17 -10
- loom/core/context_assembly.py +329 -0
- loom/core/events.py +414 -0
- loom/core/execution_context.py +119 -0
- loom/core/tool_orchestrator.py +383 -0
- loom/core/turn_state.py +188 -0
- loom/core/types.py +15 -4
- loom/interfaces/event_producer.py +172 -0
- loom/interfaces/tool.py +22 -1
- loom/security/__init__.py +13 -0
- loom/security/models.py +85 -0
- loom/security/path_validator.py +128 -0
- loom/security/validator.py +346 -0
- loom/tasks/PHASE_1_FOUNDATION/task_1.1_agent_events.md +121 -0
- loom/tasks/PHASE_1_FOUNDATION/task_1.2_streaming_api.md +521 -0
- loom/tasks/PHASE_1_FOUNDATION/task_1.3_context_assembler.md +606 -0
- loom/tasks/PHASE_2_CORE_FEATURES/task_2.1_tool_orchestrator.md +743 -0
- loom/tasks/PHASE_2_CORE_FEATURES/task_2.2_security_validator.md +676 -0
- loom/tasks/README.md +109 -0
- loom/tasks/__init__.py +11 -0
- loom/tasks/sql_placeholder.py +100 -0
- loom_agent-0.0.2.dist-info/METADATA +295 -0
- {loom_agent-0.0.1.dist-info → loom_agent-0.0.2.dist-info}/RECORD +37 -19
- loom_agent-0.0.1.dist-info/METADATA +0 -457
- {loom_agent-0.0.1.dist-info → loom_agent-0.0.2.dist-info}/WHEEL +0 -0
- {loom_agent-0.0.1.dist-info → loom_agent-0.0.2.dist-info}/licenses/LICENSE +0 -0
loom/core/agent_executor.py
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Executor with tt (Tail-Recursive) Control Loop
|
|
3
|
+
|
|
4
|
+
Core execution engine implementing recursive conversation management,
|
|
5
|
+
inspired by Claude Code's tt function design.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
from __future__ import annotations
|
|
2
9
|
|
|
3
10
|
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
4
14
|
from typing import AsyncGenerator, Dict, List, Optional
|
|
5
15
|
from uuid import uuid4
|
|
6
16
|
|
|
17
|
+
from loom.callbacks.base import BaseCallback
|
|
18
|
+
from loom.callbacks.metrics import MetricsCollector
|
|
19
|
+
from loom.core.context_assembly import ComponentPriority, ContextAssembler
|
|
20
|
+
from loom.core.events import AgentEvent, AgentEventType, ToolResult
|
|
21
|
+
from loom.core.execution_context import ExecutionContext
|
|
22
|
+
from loom.core.permissions import PermissionManager
|
|
7
23
|
from loom.core.steering_control import SteeringControl
|
|
24
|
+
from loom.core.tool_orchestrator import ToolOrchestrator
|
|
8
25
|
from loom.core.tool_pipeline import ToolExecutionPipeline
|
|
9
|
-
from loom.core.
|
|
10
|
-
from loom.core.
|
|
26
|
+
from loom.core.turn_state import TurnState
|
|
27
|
+
from loom.core.types import Message, ToolCall
|
|
11
28
|
from loom.interfaces.compressor import BaseCompressor
|
|
12
29
|
from loom.interfaces.llm import BaseLLM
|
|
13
30
|
from loom.interfaces.memory import BaseMemory
|
|
14
31
|
from loom.interfaces.tool import BaseTool
|
|
15
|
-
from loom.core.permissions import PermissionManager
|
|
16
|
-
from loom.callbacks.metrics import MetricsCollector
|
|
17
|
-
import time
|
|
18
32
|
from loom.utils.token_counter import count_messages_tokens
|
|
19
|
-
from loom.callbacks.base import BaseCallback
|
|
20
|
-
from loom.core.errors import ExecutionAbortedError
|
|
21
33
|
|
|
22
34
|
# RAG support
|
|
23
35
|
try:
|
|
@@ -27,7 +39,29 @@ except ImportError:
|
|
|
27
39
|
|
|
28
40
|
|
|
29
41
|
class AgentExecutor:
|
|
30
|
-
"""
|
|
42
|
+
"""
|
|
43
|
+
Agent Executor with tt Recursive Control Loop.
|
|
44
|
+
|
|
45
|
+
Core Design:
|
|
46
|
+
- tt() is the only execution method (tail-recursive)
|
|
47
|
+
- All other methods are thin wrappers around tt()
|
|
48
|
+
- No iteration loops - only recursion
|
|
49
|
+
- Immutable state (TurnState)
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
```python
|
|
53
|
+
executor = AgentExecutor(llm=llm, tools=tools)
|
|
54
|
+
|
|
55
|
+
# Initialize state
|
|
56
|
+
turn_state = TurnState.initial(max_iterations=10)
|
|
57
|
+
context = ExecutionContext.create()
|
|
58
|
+
messages = [Message(role="user", content="Hello")]
|
|
59
|
+
|
|
60
|
+
# Execute with tt recursion
|
|
61
|
+
async for event in executor.tt(messages, turn_state, context):
|
|
62
|
+
print(event)
|
|
63
|
+
```
|
|
64
|
+
"""
|
|
31
65
|
|
|
32
66
|
def __init__(
|
|
33
67
|
self,
|
|
@@ -35,7 +69,7 @@ class AgentExecutor:
|
|
|
35
69
|
tools: Dict[str, BaseTool] | None = None,
|
|
36
70
|
memory: BaseMemory | None = None,
|
|
37
71
|
compressor: BaseCompressor | None = None,
|
|
38
|
-
context_retriever: Optional["ContextRetriever"] = None,
|
|
72
|
+
context_retriever: Optional["ContextRetriever"] = None,
|
|
39
73
|
steering_control: SteeringControl | None = None,
|
|
40
74
|
max_iterations: int = 50,
|
|
41
75
|
max_context_tokens: int = 16000,
|
|
@@ -43,325 +77,407 @@ class AgentExecutor:
|
|
|
43
77
|
metrics: MetricsCollector | None = None,
|
|
44
78
|
system_instructions: Optional[str] = None,
|
|
45
79
|
callbacks: Optional[List[BaseCallback]] = None,
|
|
46
|
-
enable_steering: bool = False,
|
|
80
|
+
enable_steering: bool = False,
|
|
47
81
|
) -> None:
|
|
48
82
|
self.llm = llm
|
|
49
83
|
self.tools = tools or {}
|
|
50
84
|
self.memory = memory
|
|
51
85
|
self.compressor = compressor
|
|
52
|
-
self.context_retriever = context_retriever
|
|
86
|
+
self.context_retriever = context_retriever
|
|
53
87
|
self.steering_control = steering_control or SteeringControl()
|
|
54
88
|
self.max_iterations = max_iterations
|
|
55
89
|
self.max_context_tokens = max_context_tokens
|
|
56
90
|
self.metrics = metrics or MetricsCollector()
|
|
57
|
-
self.permission_manager = permission_manager or PermissionManager(
|
|
91
|
+
self.permission_manager = permission_manager or PermissionManager(
|
|
92
|
+
policy={"default": "allow"}
|
|
93
|
+
)
|
|
58
94
|
self.system_instructions = system_instructions
|
|
59
95
|
self.callbacks = callbacks or []
|
|
60
|
-
self.enable_steering = enable_steering
|
|
96
|
+
self.enable_steering = enable_steering
|
|
97
|
+
|
|
98
|
+
# Tool execution (legacy pipeline for backward compatibility)
|
|
61
99
|
self.tool_pipeline = ToolExecutionPipeline(
|
|
62
|
-
self.tools,
|
|
100
|
+
self.tools,
|
|
101
|
+
permission_manager=self.permission_manager,
|
|
102
|
+
metrics=self.metrics,
|
|
63
103
|
)
|
|
64
104
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
for cb in self.callbacks:
|
|
72
|
-
try:
|
|
73
|
-
await cb.on_event(event_type, enriched)
|
|
74
|
-
except Exception:
|
|
75
|
-
# best-effort; don't fail agent execution on callback errors
|
|
76
|
-
pass
|
|
105
|
+
# Tool orchestration (Loom 2.0 - intelligent parallel/sequential execution)
|
|
106
|
+
self.tool_orchestrator = ToolOrchestrator(
|
|
107
|
+
tools=self.tools,
|
|
108
|
+
permission_manager=self.permission_manager,
|
|
109
|
+
max_parallel=5,
|
|
110
|
+
)
|
|
77
111
|
|
|
78
|
-
|
|
112
|
+
# ==========================================
|
|
113
|
+
# CORE METHOD: tt (Tail-Recursive Control Loop)
|
|
114
|
+
# ==========================================
|
|
115
|
+
|
|
116
|
+
async def tt(
|
|
79
117
|
self,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
) ->
|
|
84
|
-
"""
|
|
118
|
+
messages: List[Message],
|
|
119
|
+
turn_state: TurnState,
|
|
120
|
+
context: ExecutionContext,
|
|
121
|
+
) -> AsyncGenerator[AgentEvent, None]:
|
|
122
|
+
"""
|
|
123
|
+
Tail-recursive control loop (inspired by Claude Code).
|
|
85
124
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
cancel_token: Optional Event to signal cancellation (US1)
|
|
89
|
-
correlation_id: Optional correlation ID for request tracing (US1)
|
|
125
|
+
This is the ONLY core execution method. It processes one turn of the
|
|
126
|
+
conversation, then recursively calls itself if tools were used.
|
|
90
127
|
|
|
91
|
-
|
|
92
|
-
|
|
128
|
+
Recursion Flow:
|
|
129
|
+
tt(messages, state_0, ctx)
|
|
130
|
+
→ LLM generates tool calls
|
|
131
|
+
→ Execute tools
|
|
132
|
+
→ tt(messages + tool_results, state_1, ctx) # Recursive call
|
|
133
|
+
→ LLM generates final answer
|
|
134
|
+
→ return (base case)
|
|
135
|
+
|
|
136
|
+
Base Cases (recursion terminates):
|
|
137
|
+
1. LLM returns final answer (no tools)
|
|
138
|
+
2. Maximum recursion depth reached
|
|
139
|
+
3. Execution cancelled
|
|
140
|
+
4. Error occurred
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
messages: New messages for this turn (not full history)
|
|
144
|
+
turn_state: Immutable turn state
|
|
145
|
+
context: Shared execution context
|
|
146
|
+
|
|
147
|
+
Yields:
|
|
148
|
+
AgentEvent: Events representing execution progress
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
```python
|
|
152
|
+
# Initial turn
|
|
153
|
+
state = TurnState.initial(max_iterations=10)
|
|
154
|
+
context = ExecutionContext.create()
|
|
155
|
+
messages = [Message(role="user", content="Search files")]
|
|
156
|
+
|
|
157
|
+
async for event in executor.tt(messages, state, context):
|
|
158
|
+
if event.type == AgentEventType.AGENT_FINISH:
|
|
159
|
+
print(f"Done: {event.content}")
|
|
160
|
+
```
|
|
93
161
|
"""
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
162
|
+
# ==========================================
|
|
163
|
+
# Phase 0: Recursion Control
|
|
164
|
+
# ==========================================
|
|
165
|
+
yield AgentEvent(
|
|
166
|
+
type=AgentEventType.ITERATION_START,
|
|
167
|
+
iteration=turn_state.turn_counter,
|
|
168
|
+
turn_id=turn_state.turn_id,
|
|
169
|
+
metadata={"parent_turn_id": turn_state.parent_turn_id},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Base case 1: Maximum recursion depth reached
|
|
173
|
+
if turn_state.is_final:
|
|
174
|
+
yield AgentEvent(
|
|
175
|
+
type=AgentEventType.MAX_ITERATIONS_REACHED,
|
|
176
|
+
metadata={
|
|
177
|
+
"turn_counter": turn_state.turn_counter,
|
|
178
|
+
"max_iterations": turn_state.max_iterations,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
await self._emit(
|
|
182
|
+
"max_iterations_reached",
|
|
183
|
+
{
|
|
184
|
+
"turn_counter": turn_state.turn_counter,
|
|
185
|
+
"max_iterations": turn_state.max_iterations,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Base case 2: Execution cancelled
|
|
191
|
+
if context.is_cancelled():
|
|
192
|
+
yield AgentEvent(
|
|
193
|
+
type=AgentEventType.EXECUTION_CANCELLED,
|
|
194
|
+
metadata={"correlation_id": context.correlation_id},
|
|
195
|
+
)
|
|
196
|
+
await self._emit(
|
|
197
|
+
"execution_cancelled",
|
|
198
|
+
{"correlation_id": context.correlation_id},
|
|
199
|
+
)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# ==========================================
|
|
203
|
+
# Phase 1: Context Assembly
|
|
204
|
+
# ==========================================
|
|
205
|
+
yield AgentEvent.phase_start("context_assembly")
|
|
113
206
|
|
|
207
|
+
# Load conversation history from memory
|
|
114
208
|
history = await self._load_history()
|
|
115
209
|
|
|
116
|
-
#
|
|
117
|
-
|
|
210
|
+
# RAG retrieval (if configured)
|
|
211
|
+
rag_context = None
|
|
118
212
|
if self.context_retriever:
|
|
119
|
-
|
|
120
|
-
if retrieved_docs:
|
|
121
|
-
# 注入检索到的文档上下文
|
|
122
|
-
if self.context_retriever.inject_as == "system":
|
|
123
|
-
doc_context = self.context_retriever.format_documents(retrieved_docs)
|
|
124
|
-
history.append(Message(
|
|
125
|
-
role="system",
|
|
126
|
-
content=doc_context,
|
|
127
|
-
metadata={"type": "retrieved_context", "doc_count": len(retrieved_docs)}
|
|
128
|
-
))
|
|
129
|
-
# 记录检索指标
|
|
130
|
-
self.metrics.metrics.retrievals = getattr(self.metrics.metrics, "retrievals", 0) + 1
|
|
131
|
-
await self._emit("retrieval_complete", {"doc_count": len(retrieved_docs), "source": "execute"})
|
|
132
|
-
|
|
133
|
-
# Step 2: 添加用户消息
|
|
134
|
-
history.append(Message(role="user", content=user_input))
|
|
135
|
-
|
|
136
|
-
# Step 3: 压缩检查
|
|
137
|
-
history = await self._maybe_compress(history)
|
|
138
|
-
|
|
139
|
-
# Step 4: 动态生成系统提示
|
|
140
|
-
context = {"retrieved_docs_count": len(retrieved_docs)} if retrieved_docs else None
|
|
141
|
-
system_prompt = build_system_prompt(self.tools, self.system_instructions, context)
|
|
142
|
-
history = self._inject_system_prompt(history, system_prompt)
|
|
143
|
-
|
|
144
|
-
if not self.llm.supports_tools or not self.tools:
|
|
145
|
-
try:
|
|
146
|
-
# Create LLM task that can be cancelled
|
|
147
|
-
llm_task = asyncio.create_task(self.llm.generate([m.__dict__ for m in history]))
|
|
148
|
-
|
|
149
|
-
# Poll for cancellation while waiting for LLM
|
|
150
|
-
while not llm_task.done():
|
|
151
|
-
if cancel_token and cancel_token.is_set():
|
|
152
|
-
llm_task.cancel()
|
|
153
|
-
try:
|
|
154
|
-
await llm_task
|
|
155
|
-
except asyncio.CancelledError:
|
|
156
|
-
pass
|
|
157
|
-
partial_result = "Execution interrupted during LLM call"
|
|
158
|
-
await self._emit("agent_finish", {
|
|
159
|
-
"content": partial_result,
|
|
160
|
-
"source": "execute",
|
|
161
|
-
"correlation_id": correlation_id,
|
|
162
|
-
"interrupted": True,
|
|
163
|
-
})
|
|
164
|
-
return partial_result
|
|
165
|
-
await asyncio.sleep(0.1) # Check every 100ms
|
|
166
|
-
|
|
167
|
-
text = await llm_task
|
|
168
|
-
except asyncio.CancelledError:
|
|
169
|
-
partial_result = "Execution interrupted during LLM call"
|
|
170
|
-
await self._emit("agent_finish", {
|
|
171
|
-
"content": partial_result,
|
|
172
|
-
"source": "execute",
|
|
173
|
-
"correlation_id": correlation_id,
|
|
174
|
-
"interrupted": True,
|
|
175
|
-
})
|
|
176
|
-
return partial_result
|
|
177
|
-
except Exception as e:
|
|
178
|
-
self.metrics.metrics.total_errors += 1
|
|
179
|
-
await self._emit("error", {"stage": "llm_generate", "message": str(e)})
|
|
180
|
-
raise
|
|
181
|
-
self.metrics.metrics.llm_calls += 1
|
|
182
|
-
if self.memory:
|
|
183
|
-
await self.memory.add_message(Message(role="assistant", content=text))
|
|
184
|
-
await self._emit("agent_finish", {"content": text, "source": "execute"})
|
|
185
|
-
return text
|
|
186
|
-
|
|
187
|
-
tools_spec = self._serialize_tools()
|
|
188
|
-
iterations = 0
|
|
189
|
-
final_text = ""
|
|
190
|
-
while iterations < self.max_iterations:
|
|
191
|
-
# 🆕 US1: Check cancellation before each iteration
|
|
192
|
-
if cancel_token and cancel_token.is_set():
|
|
193
|
-
partial_result = f"Execution interrupted after {iterations} iterations. Partial progress: {final_text or '(in progress)'}"
|
|
194
|
-
await self._emit("agent_finish", {
|
|
195
|
-
"content": partial_result,
|
|
196
|
-
"source": "execute",
|
|
197
|
-
"correlation_id": correlation_id,
|
|
198
|
-
"interrupted": True,
|
|
199
|
-
"iterations_completed": iterations,
|
|
200
|
-
})
|
|
201
|
-
return partial_result
|
|
213
|
+
yield AgentEvent(type=AgentEventType.RETRIEVAL_START)
|
|
202
214
|
|
|
203
215
|
try:
|
|
204
|
-
|
|
216
|
+
# Extract user query from last message
|
|
217
|
+
user_query = ""
|
|
218
|
+
for msg in reversed(messages):
|
|
219
|
+
if msg.role == "user":
|
|
220
|
+
user_query = msg.content
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
if user_query:
|
|
224
|
+
retrieved_docs = await self.context_retriever.retrieve_for_query(
|
|
225
|
+
user_query
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if retrieved_docs:
|
|
229
|
+
rag_context = self.context_retriever.format_documents(
|
|
230
|
+
retrieved_docs
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Emit retrieval progress
|
|
234
|
+
for doc in retrieved_docs:
|
|
235
|
+
yield AgentEvent(
|
|
236
|
+
type=AgentEventType.RETRIEVAL_PROGRESS,
|
|
237
|
+
metadata={
|
|
238
|
+
"doc_title": doc.metadata.get("title", "Unknown"),
|
|
239
|
+
"relevance_score": doc.metadata.get("score", 0.0),
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
yield AgentEvent(
|
|
244
|
+
type=AgentEventType.RETRIEVAL_COMPLETE,
|
|
245
|
+
metadata={"doc_count": len(retrieved_docs)},
|
|
246
|
+
)
|
|
247
|
+
self.metrics.metrics.retrievals = (
|
|
248
|
+
getattr(self.metrics.metrics, "retrievals", 0) + 1
|
|
249
|
+
)
|
|
250
|
+
|
|
205
251
|
except Exception as e:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
]
|
|
226
|
-
await self._emit("tool_calls_start", {"tool_calls": meta, "source": "execute", "iteration": iterations})
|
|
227
|
-
except Exception:
|
|
228
|
-
pass
|
|
229
|
-
# 执行工具并把结果写回消息
|
|
230
|
-
try:
|
|
231
|
-
for tr in await self._execute_tool_batch(tool_calls):
|
|
232
|
-
tool_msg = Message(role="tool", content=tr.content, tool_call_id=tr.tool_call_id)
|
|
233
|
-
history.append(tool_msg)
|
|
234
|
-
if self.memory:
|
|
235
|
-
await self.memory.add_message(tool_msg)
|
|
236
|
-
await self._emit("tool_result", {"tool_call_id": tr.tool_call_id, "content": tr.content, "source": "execute", "iteration": iterations})
|
|
237
|
-
except Exception as e:
|
|
238
|
-
self.metrics.metrics.total_errors += 1
|
|
239
|
-
await self._emit("error", {"stage": "tool_execute", "message": str(e), "source": "execute", "iteration": iterations})
|
|
240
|
-
raise
|
|
241
|
-
iterations += 1
|
|
242
|
-
self.metrics.metrics.total_iterations += 1
|
|
243
|
-
history = await self._maybe_compress(history)
|
|
244
|
-
continue
|
|
245
|
-
|
|
246
|
-
# 无工具调用:认为生成最终答案
|
|
247
|
-
final_text = content
|
|
248
|
-
if self.memory:
|
|
249
|
-
await self.memory.add_message(Message(role="assistant", content=final_text))
|
|
250
|
-
await self._emit("agent_finish", {
|
|
251
|
-
"content": final_text,
|
|
252
|
-
"source": "execute",
|
|
253
|
-
"correlation_id": correlation_id, # 🆕 US1
|
|
254
|
-
})
|
|
255
|
-
break
|
|
256
|
-
|
|
257
|
-
return final_text
|
|
258
|
-
|
|
259
|
-
async def stream(self, user_input: str) -> AsyncGenerator[StreamEvent, None]:
|
|
260
|
-
"""流式执行:输出 text_delta/agent_finish 事件。后续可接入 tool_calls。"""
|
|
261
|
-
yield StreamEvent(type="request_start")
|
|
262
|
-
await self._emit("request_start", {"input": user_input, "source": "stream", "iteration": 0})
|
|
263
|
-
history = await self._load_history()
|
|
252
|
+
yield AgentEvent.error(e, retrieval_failed=True)
|
|
253
|
+
|
|
254
|
+
# Add new messages to history
|
|
255
|
+
history.extend(messages)
|
|
256
|
+
|
|
257
|
+
# Compression check
|
|
258
|
+
old_len = len(history)
|
|
259
|
+
history_compacted = await self._maybe_compress(history)
|
|
260
|
+
compacted_this_turn = len(history_compacted) < old_len
|
|
261
|
+
|
|
262
|
+
if compacted_this_turn:
|
|
263
|
+
history = history_compacted
|
|
264
|
+
yield AgentEvent(
|
|
265
|
+
type=AgentEventType.COMPRESSION_APPLIED,
|
|
266
|
+
metadata={
|
|
267
|
+
"messages_before": old_len,
|
|
268
|
+
"messages_after": len(history),
|
|
269
|
+
},
|
|
270
|
+
)
|
|
264
271
|
|
|
265
|
-
#
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
272
|
+
# Assemble system prompt using ContextAssembler
|
|
273
|
+
assembler = ContextAssembler(max_tokens=self.max_context_tokens)
|
|
274
|
+
|
|
275
|
+
# Add base instructions (critical priority)
|
|
276
|
+
if self.system_instructions:
|
|
277
|
+
assembler.add_component(
|
|
278
|
+
name="base_instructions",
|
|
279
|
+
content=self.system_instructions,
|
|
280
|
+
priority=ComponentPriority.CRITICAL,
|
|
281
|
+
truncatable=False,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Add RAG context (high priority)
|
|
285
|
+
if rag_context:
|
|
286
|
+
assembler.add_component(
|
|
287
|
+
name="retrieved_context",
|
|
288
|
+
content=rag_context,
|
|
289
|
+
priority=ComponentPriority.HIGH,
|
|
290
|
+
truncatable=True,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Add tool definitions (medium priority)
|
|
294
|
+
if self.tools:
|
|
295
|
+
tools_spec = self._serialize_tools()
|
|
296
|
+
tools_prompt = f"Available tools:\n{json.dumps(tools_spec, indent=2)}"
|
|
297
|
+
assembler.add_component(
|
|
298
|
+
name="tool_definitions",
|
|
299
|
+
content=tools_prompt,
|
|
300
|
+
priority=ComponentPriority.MEDIUM,
|
|
301
|
+
truncatable=False,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Assemble final system prompt
|
|
305
|
+
final_system_prompt = assembler.assemble()
|
|
306
|
+
|
|
307
|
+
# Inject system prompt into history
|
|
308
|
+
if history and history[0].role == "system":
|
|
309
|
+
history[0] = Message(role="system", content=final_system_prompt)
|
|
310
|
+
else:
|
|
311
|
+
history.insert(0, Message(role="system", content=final_system_prompt))
|
|
312
|
+
|
|
313
|
+
# Emit context assembly summary
|
|
314
|
+
summary = assembler.get_summary()
|
|
315
|
+
yield AgentEvent.phase_end(
|
|
316
|
+
"context_assembly",
|
|
317
|
+
tokens_used=summary["total_tokens"],
|
|
318
|
+
metadata={
|
|
319
|
+
"components": len(summary["components"]),
|
|
320
|
+
"utilization": summary["utilization"],
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# ==========================================
|
|
325
|
+
# Phase 2: LLM Call
|
|
326
|
+
# ==========================================
|
|
327
|
+
yield AgentEvent(type=AgentEventType.LLM_START)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
if self.llm.supports_tools and self.tools:
|
|
331
|
+
# LLM with tool support
|
|
332
|
+
tools_spec = self._serialize_tools()
|
|
333
|
+
response = await self.llm.generate_with_tools(
|
|
334
|
+
[m.__dict__ for m in history], tools_spec
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
content = response.get("content", "")
|
|
338
|
+
tool_calls = response.get("tool_calls", [])
|
|
339
|
+
|
|
340
|
+
# Emit LLM content if available
|
|
341
|
+
if content:
|
|
342
|
+
yield AgentEvent(type=AgentEventType.LLM_DELTA, content=content)
|
|
343
|
+
|
|
344
|
+
else:
|
|
345
|
+
# Simple LLM generation (streaming)
|
|
346
|
+
content_parts = []
|
|
297
347
|
async for delta in self.llm.stream([m.__dict__ for m in history]):
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
348
|
+
content_parts.append(delta)
|
|
349
|
+
yield AgentEvent(type=AgentEventType.LLM_DELTA, content=delta)
|
|
350
|
+
|
|
351
|
+
content = "".join(content_parts)
|
|
352
|
+
tool_calls = []
|
|
353
|
+
|
|
354
|
+
yield AgentEvent(type=AgentEventType.LLM_COMPLETE)
|
|
355
|
+
|
|
356
|
+
except Exception as e:
|
|
357
|
+
self.metrics.metrics.total_errors += 1
|
|
358
|
+
yield AgentEvent.error(e, llm_failed=True)
|
|
359
|
+
await self._emit("error", {"stage": "llm_call", "message": str(e)})
|
|
304
360
|
return
|
|
305
361
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
362
|
+
self.metrics.metrics.llm_calls += 1
|
|
363
|
+
|
|
364
|
+
# ==========================================
|
|
365
|
+
# Phase 3: Decision Point (Base Case or Recurse)
|
|
366
|
+
# ==========================================
|
|
367
|
+
|
|
368
|
+
if not tool_calls:
|
|
369
|
+
# Base case: No tools → Conversation complete
|
|
370
|
+
yield AgentEvent(
|
|
371
|
+
type=AgentEventType.AGENT_FINISH,
|
|
372
|
+
content=content,
|
|
373
|
+
metadata={
|
|
374
|
+
"turn_counter": turn_state.turn_counter,
|
|
375
|
+
"total_llm_calls": self.metrics.metrics.llm_calls,
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Save to memory
|
|
380
|
+
if self.memory and content:
|
|
381
|
+
await self.memory.add_message(
|
|
382
|
+
Message(role="assistant", content=content)
|
|
326
383
|
)
|
|
327
|
-
|
|
328
|
-
try:
|
|
329
|
-
async for tr in self._execute_tool_calls_async(tc_models):
|
|
330
|
-
yield StreamEvent(type="tool_result", result=tr)
|
|
331
|
-
await self._emit("tool_result", {"tool_call_id": tr.tool_call_id, "content": tr.content, "source": "stream", "iteration": iterations})
|
|
332
|
-
tool_msg = Message(role="tool", content=tr.content, tool_call_id=tr.tool_call_id)
|
|
333
|
-
history.append(tool_msg)
|
|
334
|
-
if self.memory:
|
|
335
|
-
await self.memory.add_message(tool_msg)
|
|
336
|
-
except Exception as e:
|
|
337
|
-
self.metrics.metrics.total_errors += 1
|
|
338
|
-
await self._emit("error", {"stage": "tool_execute", "message": str(e), "source": "stream", "iteration": iterations})
|
|
339
|
-
raise
|
|
340
|
-
iterations += 1
|
|
341
|
-
self.metrics.metrics.total_iterations += 1
|
|
342
|
-
# 每轮结束后做压缩检查
|
|
343
|
-
history = await self._maybe_compress(history)
|
|
344
|
-
continue
|
|
345
|
-
|
|
346
|
-
# 无工具调用:输出最终文本并结束
|
|
347
|
-
if content:
|
|
348
|
-
yield StreamEvent(type="text_delta", content=content)
|
|
349
|
-
yield StreamEvent(type="agent_finish")
|
|
384
|
+
|
|
350
385
|
await self._emit("agent_finish", {"content": content})
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
# ==========================================
|
|
389
|
+
# Phase 4: Tool Execution
|
|
390
|
+
# ==========================================
|
|
391
|
+
yield AgentEvent(
|
|
392
|
+
type=AgentEventType.LLM_TOOL_CALLS,
|
|
393
|
+
metadata={
|
|
394
|
+
"tool_count": len(tool_calls),
|
|
395
|
+
"tool_names": [tc.get("name") for tc in tool_calls],
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Convert to ToolCall models
|
|
400
|
+
tc_models = [self._to_tool_call(tc) for tc in tool_calls]
|
|
401
|
+
|
|
402
|
+
# Execute tools using ToolOrchestrator
|
|
403
|
+
tool_results: List[ToolResult] = []
|
|
404
|
+
try:
|
|
405
|
+
async for event in self.tool_orchestrator.execute_batch(tc_models):
|
|
406
|
+
yield event # Forward all tool events
|
|
407
|
+
|
|
408
|
+
if event.type == AgentEventType.TOOL_RESULT:
|
|
409
|
+
tool_results.append(event.tool_result)
|
|
410
|
+
|
|
411
|
+
# Add to memory
|
|
412
|
+
tool_msg = Message(
|
|
413
|
+
role="tool",
|
|
414
|
+
content=event.tool_result.content,
|
|
415
|
+
tool_call_id=event.tool_result.tool_call_id,
|
|
416
|
+
)
|
|
417
|
+
if self.memory:
|
|
418
|
+
await self.memory.add_message(tool_msg)
|
|
419
|
+
|
|
420
|
+
elif event.type == AgentEventType.TOOL_ERROR:
|
|
421
|
+
# Collect error results too
|
|
422
|
+
if event.tool_result:
|
|
423
|
+
tool_results.append(event.tool_result)
|
|
424
|
+
|
|
425
|
+
except Exception as e:
|
|
426
|
+
self.metrics.metrics.total_errors += 1
|
|
427
|
+
yield AgentEvent.error(e, tool_execution_failed=True)
|
|
428
|
+
await self._emit("error", {"stage": "tool_execution", "message": str(e)})
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
yield AgentEvent(
|
|
432
|
+
type=AgentEventType.TOOL_CALLS_COMPLETE,
|
|
433
|
+
metadata={"results_count": len(tool_results)},
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
self.metrics.metrics.total_iterations += 1
|
|
437
|
+
|
|
438
|
+
# ==========================================
|
|
439
|
+
# Phase 5: Recursive Call (Tail Recursion)
|
|
440
|
+
# ==========================================
|
|
441
|
+
|
|
442
|
+
# Prepare next turn state
|
|
443
|
+
next_state = turn_state.next_turn(compacted=compacted_this_turn)
|
|
444
|
+
|
|
445
|
+
# Prepare next turn messages (only new messages, not full history)
|
|
446
|
+
next_messages = [
|
|
447
|
+
Message(
|
|
448
|
+
role="tool",
|
|
449
|
+
content=r.content,
|
|
450
|
+
tool_call_id=r.tool_call_id,
|
|
451
|
+
)
|
|
452
|
+
for r in tool_results
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
# Emit recursion event
|
|
456
|
+
yield AgentEvent(
|
|
457
|
+
type=AgentEventType.RECURSION,
|
|
458
|
+
metadata={
|
|
459
|
+
"from_turn": turn_state.turn_id,
|
|
460
|
+
"to_turn": next_state.turn_id,
|
|
461
|
+
"depth": next_state.turn_counter,
|
|
462
|
+
},
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# 🔥 Tail-recursive call
|
|
466
|
+
async for event in self.tt(next_messages, next_state, context):
|
|
467
|
+
yield event
|
|
468
|
+
|
|
469
|
+
# ==========================================
|
|
470
|
+
# Helper Methods
|
|
471
|
+
# ==========================================
|
|
354
472
|
|
|
355
473
|
async def _load_history(self) -> List[Message]:
|
|
474
|
+
"""Load conversation history from memory."""
|
|
356
475
|
if not self.memory:
|
|
357
476
|
return []
|
|
358
477
|
return await self.memory.get_messages()
|
|
359
478
|
|
|
360
479
|
async def _maybe_compress(self, history: List[Message]) -> List[Message]:
|
|
361
|
-
"""Check if compression needed and apply if threshold reached.
|
|
362
|
-
|
|
363
|
-
US2: Automatic compression at 92% threshold with 8-segment summarization.
|
|
364
|
-
"""
|
|
480
|
+
"""Check if compression needed and apply if threshold reached."""
|
|
365
481
|
if not self.compressor:
|
|
366
482
|
return history
|
|
367
483
|
|
|
@@ -369,16 +485,19 @@ class AgentExecutor:
|
|
|
369
485
|
|
|
370
486
|
# Check if compression should be triggered (92% threshold)
|
|
371
487
|
if self.compressor.should_compress(tokens_before, self.max_context_tokens):
|
|
372
|
-
# Attempt compression
|
|
373
488
|
try:
|
|
374
489
|
compressed_messages, metadata = await self.compressor.compress(history)
|
|
375
490
|
|
|
376
491
|
# Update metrics
|
|
377
|
-
self.metrics.metrics.compressions =
|
|
492
|
+
self.metrics.metrics.compressions = (
|
|
493
|
+
getattr(self.metrics.metrics, "compressions", 0) + 1
|
|
494
|
+
)
|
|
378
495
|
if metadata.key_topics == ["fallback"]:
|
|
379
|
-
self.metrics.metrics.compression_fallbacks =
|
|
496
|
+
self.metrics.metrics.compression_fallbacks = (
|
|
497
|
+
getattr(self.metrics.metrics, "compression_fallbacks", 0) + 1
|
|
498
|
+
)
|
|
380
499
|
|
|
381
|
-
# Emit compression event
|
|
500
|
+
# Emit compression event
|
|
382
501
|
await self._emit(
|
|
383
502
|
"compression_applied",
|
|
384
503
|
{
|
|
@@ -395,17 +514,17 @@ class AgentExecutor:
|
|
|
395
514
|
return compressed_messages
|
|
396
515
|
|
|
397
516
|
except Exception as e:
|
|
398
|
-
# Compression failed - continue without compression
|
|
399
517
|
self.metrics.metrics.total_errors += 1
|
|
400
|
-
await self._emit(
|
|
401
|
-
"
|
|
402
|
-
"message": str(e),
|
|
403
|
-
|
|
518
|
+
await self._emit(
|
|
519
|
+
"error",
|
|
520
|
+
{"stage": "compression", "message": str(e)},
|
|
521
|
+
)
|
|
404
522
|
return history
|
|
405
523
|
|
|
406
524
|
return history
|
|
407
525
|
|
|
408
526
|
def _serialize_tools(self) -> List[Dict]:
|
|
527
|
+
"""Serialize tools to LLM-compatible format."""
|
|
409
528
|
tools_spec: List[Dict] = []
|
|
410
529
|
for t in self.tools.values():
|
|
411
530
|
schema = {}
|
|
@@ -413,6 +532,7 @@ class AgentExecutor:
|
|
|
413
532
|
schema = t.args_schema.model_json_schema() # type: ignore[attr-defined]
|
|
414
533
|
except Exception:
|
|
415
534
|
schema = {"type": "object", "properties": {}}
|
|
535
|
+
|
|
416
536
|
tools_spec.append(
|
|
417
537
|
{
|
|
418
538
|
"type": "function",
|
|
@@ -426,25 +546,90 @@ class AgentExecutor:
|
|
|
426
546
|
return tools_spec
|
|
427
547
|
|
|
428
548
|
def _to_tool_call(self, raw: Dict) -> ToolCall:
|
|
429
|
-
|
|
430
|
-
return ToolCall(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
549
|
+
"""Convert raw dict to ToolCall model."""
|
|
550
|
+
return ToolCall(
|
|
551
|
+
id=str(raw.get("id", "call_0")),
|
|
552
|
+
name=raw["name"],
|
|
553
|
+
arguments=raw.get("arguments", {}),
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
async def _emit(self, event_type: str, payload: Dict) -> None:
|
|
557
|
+
"""Emit event to callbacks."""
|
|
558
|
+
if not self.callbacks:
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
enriched = dict(payload)
|
|
562
|
+
enriched.setdefault("ts", time.time())
|
|
563
|
+
enriched.setdefault("type", event_type)
|
|
564
|
+
|
|
565
|
+
for cb in self.callbacks:
|
|
566
|
+
try:
|
|
567
|
+
await cb.on_event(event_type, enriched)
|
|
568
|
+
except Exception:
|
|
569
|
+
# Best-effort; don't fail execution on callback errors
|
|
570
|
+
pass
|
|
571
|
+
|
|
572
|
+
# ==========================================
|
|
573
|
+
# Backward Compatibility Wrappers
|
|
574
|
+
# ==========================================
|
|
575
|
+
|
|
576
|
+
async def execute(
|
|
577
|
+
self,
|
|
578
|
+
user_input: str,
|
|
579
|
+
cancel_token: Optional[asyncio.Event] = None,
|
|
580
|
+
correlation_id: Optional[str] = None,
|
|
581
|
+
) -> str:
|
|
582
|
+
"""
|
|
583
|
+
Execute agent and return final response (backward compatible wrapper).
|
|
584
|
+
|
|
585
|
+
This method wraps the new tt() recursive API and extracts the final
|
|
586
|
+
response for backward compatibility with existing code.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
user_input: User input text
|
|
590
|
+
cancel_token: Optional cancellation event
|
|
591
|
+
correlation_id: Optional correlation ID for tracing
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
str: Final response text
|
|
595
|
+
|
|
596
|
+
Example:
|
|
597
|
+
```python
|
|
598
|
+
executor = AgentExecutor(llm=llm, tools=tools)
|
|
599
|
+
response = await executor.execute("Hello")
|
|
600
|
+
print(response)
|
|
601
|
+
```
|
|
602
|
+
"""
|
|
603
|
+
# Initialize state and context
|
|
604
|
+
turn_state = TurnState.initial(max_iterations=self.max_iterations)
|
|
605
|
+
context = ExecutionContext.create(
|
|
606
|
+
correlation_id=correlation_id,
|
|
607
|
+
cancel_token=cancel_token,
|
|
608
|
+
)
|
|
609
|
+
messages = [Message(role="user", content=user_input)]
|
|
610
|
+
|
|
611
|
+
# Execute with tt and collect result
|
|
612
|
+
final_content = ""
|
|
613
|
+
async for event in self.tt(messages, turn_state, context):
|
|
614
|
+
# Accumulate LLM deltas
|
|
615
|
+
if event.type == AgentEventType.LLM_DELTA:
|
|
616
|
+
final_content += event.content or ""
|
|
617
|
+
|
|
618
|
+
# Return on finish
|
|
619
|
+
elif event.type == AgentEventType.AGENT_FINISH:
|
|
620
|
+
return event.content or final_content
|
|
621
|
+
|
|
622
|
+
# Handle cancellation
|
|
623
|
+
elif event.type == AgentEventType.EXECUTION_CANCELLED:
|
|
624
|
+
return "cancelled"
|
|
625
|
+
|
|
626
|
+
# Handle max iterations
|
|
627
|
+
elif event.type == AgentEventType.MAX_ITERATIONS_REACHED:
|
|
628
|
+
return final_content or "Max iterations reached"
|
|
629
|
+
|
|
630
|
+
# Raise on error
|
|
631
|
+
elif event.type == AgentEventType.ERROR:
|
|
632
|
+
if event.error:
|
|
633
|
+
raise event.error
|
|
634
|
+
|
|
635
|
+
return final_content
|