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.

Files changed (38) hide show
  1. loom/builtin/tools/calculator.py +4 -0
  2. loom/builtin/tools/document_search.py +5 -0
  3. loom/builtin/tools/glob.py +4 -0
  4. loom/builtin/tools/grep.py +4 -0
  5. loom/builtin/tools/http_request.py +5 -0
  6. loom/builtin/tools/python_repl.py +5 -0
  7. loom/builtin/tools/read_file.py +4 -0
  8. loom/builtin/tools/task.py +5 -0
  9. loom/builtin/tools/web_search.py +4 -0
  10. loom/builtin/tools/write_file.py +4 -0
  11. loom/components/agent.py +121 -5
  12. loom/core/agent_executor.py +505 -320
  13. loom/core/compression_manager.py +17 -10
  14. loom/core/context_assembly.py +329 -0
  15. loom/core/events.py +414 -0
  16. loom/core/execution_context.py +119 -0
  17. loom/core/tool_orchestrator.py +383 -0
  18. loom/core/turn_state.py +188 -0
  19. loom/core/types.py +15 -4
  20. loom/interfaces/event_producer.py +172 -0
  21. loom/interfaces/tool.py +22 -1
  22. loom/security/__init__.py +13 -0
  23. loom/security/models.py +85 -0
  24. loom/security/path_validator.py +128 -0
  25. loom/security/validator.py +346 -0
  26. loom/tasks/PHASE_1_FOUNDATION/task_1.1_agent_events.md +121 -0
  27. loom/tasks/PHASE_1_FOUNDATION/task_1.2_streaming_api.md +521 -0
  28. loom/tasks/PHASE_1_FOUNDATION/task_1.3_context_assembler.md +606 -0
  29. loom/tasks/PHASE_2_CORE_FEATURES/task_2.1_tool_orchestrator.md +743 -0
  30. loom/tasks/PHASE_2_CORE_FEATURES/task_2.2_security_validator.md +676 -0
  31. loom/tasks/README.md +109 -0
  32. loom/tasks/__init__.py +11 -0
  33. loom/tasks/sql_placeholder.py +100 -0
  34. loom_agent-0.0.2.dist-info/METADATA +295 -0
  35. {loom_agent-0.0.1.dist-info → loom_agent-0.0.2.dist-info}/RECORD +37 -19
  36. loom_agent-0.0.1.dist-info/METADATA +0 -457
  37. {loom_agent-0.0.1.dist-info → loom_agent-0.0.2.dist-info}/WHEEL +0 -0
  38. {loom_agent-0.0.1.dist-info → loom_agent-0.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -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.types import Message, StreamEvent, ToolCall
10
- from loom.core.system_prompt import build_system_prompt
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
- """Agent 执行器:封装主循环,连接 LLM、内存、工具流水线与事件流。"""
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, # 🆕 RAG support
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, # 🆕 US1: Real-time steering
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 # 🆕 RAG support
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(policy={"default": "allow"})
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 # 🆕 US1
96
+ self.enable_steering = enable_steering
97
+
98
+ # Tool execution (legacy pipeline for backward compatibility)
61
99
  self.tool_pipeline = ToolExecutionPipeline(
62
- self.tools, permission_manager=self.permission_manager, metrics=self.metrics
100
+ self.tools,
101
+ permission_manager=self.permission_manager,
102
+ metrics=self.metrics,
63
103
  )
64
104
 
65
- async def _emit(self, event_type: str, payload: Dict) -> None:
66
- if not self.callbacks:
67
- return
68
- enriched = dict(payload)
69
- enriched.setdefault("ts", time.time())
70
- enriched.setdefault("type", event_type)
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
- async def execute(
112
+ # ==========================================
113
+ # CORE METHOD: tt (Tail-Recursive Control Loop)
114
+ # ==========================================
115
+
116
+ async def tt(
79
117
  self,
80
- user_input: str,
81
- cancel_token: Optional[asyncio.Event] = None, # 🆕 US1: Cancellation support
82
- correlation_id: Optional[str] = None, # 🆕 US1: Request tracing
83
- ) -> str:
84
- """非流式执行,包含工具调用的 ReAct 循环(最小实现)。
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
- Args:
87
- user_input: User query/instruction
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
- Returns:
92
- Final agent response (or partial results if cancelled)
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
- # Generate correlation_id if not provided
95
- if correlation_id is None:
96
- correlation_id = str(uuid4())
97
-
98
- await self._emit("request_start", {
99
- "input": user_input,
100
- "source": "execute",
101
- "iteration": 0,
102
- "correlation_id": correlation_id, # 🆕 US1
103
- })
104
-
105
- # Check cancellation before starting
106
- if cancel_token and cancel_token.is_set():
107
- await self._emit("agent_finish", {
108
- "content": "Request cancelled before execution",
109
- "source": "execute",
110
- "correlation_id": correlation_id,
111
- })
112
- return "Request cancelled before execution"
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
- # 🆕 Step 1: RAG - 自动检索相关文档(如果配置了 context_retriever)
117
- retrieved_docs = []
210
+ # RAG retrieval (if configured)
211
+ rag_context = None
118
212
  if self.context_retriever:
119
- retrieved_docs = await self.context_retriever.retrieve_for_query(user_input)
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
- resp = await self.llm.generate_with_tools([m.__dict__ for m in history], tools_spec)
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
- self.metrics.metrics.total_errors += 1
207
- await self._emit("error", {
208
- "stage": "llm_generate_with_tools",
209
- "message": str(e),
210
- "source": "execute",
211
- "iteration": iterations,
212
- "correlation_id": correlation_id, # 🆕 US1
213
- })
214
- raise
215
- self.metrics.metrics.llm_calls += 1
216
- tool_calls = resp.get("tool_calls") or []
217
- content = resp.get("content") or ""
218
-
219
- if tool_calls:
220
- # 广播工具调用开始(非流式路径)
221
- try:
222
- meta = [
223
- {"id": str(tc.get("id", "")), "name": str(tc.get("name", ""))}
224
- for tc in tool_calls
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
- # 🆕 RAG - 自动检索文档
266
- retrieved_docs = []
267
- if self.context_retriever:
268
- retrieved_docs = await self.context_retriever.retrieve_for_query(user_input)
269
- if retrieved_docs:
270
- if self.context_retriever.inject_as == "system":
271
- doc_context = self.context_retriever.format_documents(retrieved_docs)
272
- history.append(Message(
273
- role="system",
274
- content=doc_context,
275
- metadata={"type": "retrieved_context", "doc_count": len(retrieved_docs)}
276
- ))
277
- self.metrics.metrics.retrievals = getattr(self.metrics.metrics, "retrievals", 0) + 1
278
- # 🆕 广播检索事件
279
- yield StreamEvent(type="retrieval_complete", metadata={"doc_count": len(retrieved_docs)})
280
- await self._emit("retrieval_complete", {"doc_count": len(retrieved_docs), "source": "stream"})
281
-
282
- history.append(Message(role="user", content=user_input))
283
-
284
- # 压缩检查
285
- compressed = await self._maybe_compress(history)
286
- if compressed is not history:
287
- history = compressed
288
- yield StreamEvent(type="compression_applied")
289
-
290
- # 动态生成系统提示
291
- context = {"retrieved_docs_count": len(retrieved_docs)} if retrieved_docs else None
292
- system_prompt = build_system_prompt(self.tools, self.system_instructions, context)
293
- history = self._inject_system_prompt(history, system_prompt)
294
-
295
- if not self.llm.supports_tools or not self.tools:
296
- try:
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
- yield StreamEvent(type="text_delta", content=delta)
299
- except Exception as e:
300
- self.metrics.metrics.total_errors += 1
301
- await self._emit("error", {"stage": "llm_stream", "message": str(e), "source": "stream"})
302
- raise
303
- yield StreamEvent(type="agent_finish")
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
- tools_spec = self._serialize_tools()
307
- iterations = 0
308
- while iterations < self.max_iterations:
309
- try:
310
- resp = await self.llm.generate_with_tools([m.__dict__ for m in history], tools_spec)
311
- except Exception as e:
312
- self.metrics.metrics.total_errors += 1
313
- await self._emit("error", {"stage": "llm_generate_with_tools", "message": str(e), "source": "stream", "iteration": iterations})
314
- raise
315
- self.metrics.metrics.llm_calls += 1
316
- tool_calls = resp.get("tool_calls") or []
317
- content = resp.get("content") or ""
318
-
319
- if tool_calls:
320
- # 广播工具调用开始
321
- tc_models = [self._to_tool_call(tc) for tc in tool_calls]
322
- yield StreamEvent(type="tool_calls_start", tool_calls=tc_models)
323
- await self._emit(
324
- "tool_calls_start",
325
- {"tool_calls": [{"id": t.id, "name": t.name} for t in tc_models], "source": "stream", "iteration": iterations},
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
- if self.memory and content:
352
- await self.memory.add_message(Message(role="assistant", content=content))
353
- break
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 = getattr(self.metrics.metrics, "compressions", 0) + 1
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 = getattr(self.metrics.metrics, "compression_fallbacks", 0) + 1
496
+ self.metrics.metrics.compression_fallbacks = (
497
+ getattr(self.metrics.metrics, "compression_fallbacks", 0) + 1
498
+ )
380
499
 
381
- # Emit compression event with metadata
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("error", {
401
- "stage": "compression",
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
- # 允许 Rule/Mock LLM 输出简单 dict
430
- return ToolCall(id=str(raw.get("id", "call_0")), name=raw["name"], arguments=raw.get("arguments", {}))
431
-
432
- async def _execute_tool_batch(self, tool_calls_raw: List[Dict]) -> List[ToolResult]:
433
- tc_models = [self._to_tool_call(tc) for tc in tool_calls_raw]
434
- results: List[ToolResult] = []
435
- async for tr in self._execute_tool_calls_async(tc_models):
436
- results.append(tr)
437
- return results
438
-
439
- async def _execute_tool_calls_async(self, tool_calls: List[ToolCall]):
440
- async for tr in self.tool_pipeline.execute_calls(tool_calls):
441
- yield tr
442
-
443
- def _inject_system_prompt(self, history: List[Message], system_prompt: str) -> List[Message]:
444
- """注入或更新系统提示消息"""
445
- # 如果第一条是系统消息,则替换;否则在开头插入
446
- if history and history[0].role == "system":
447
- history[0] = Message(role="system", content=system_prompt)
448
- else:
449
- history.insert(0, Message(role="system", content=system_prompt))
450
- return history
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