agent-runtime-core 0.8.0__py3-none-any.whl → 0.9.1__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.
- agent_runtime_core/__init__.py +65 -3
- agent_runtime_core/agentic_loop.py +285 -20
- agent_runtime_core/config.py +8 -0
- agent_runtime_core/contexts.py +72 -4
- agent_runtime_core/interfaces.py +29 -11
- agent_runtime_core/llm/anthropic.py +161 -7
- agent_runtime_core/llm/models_config.py +50 -6
- agent_runtime_core/llm/openai.py +51 -2
- agent_runtime_core/multi_agent.py +1419 -17
- agent_runtime_core/persistence/__init__.py +8 -0
- agent_runtime_core/persistence/base.py +318 -1
- agent_runtime_core/persistence/file.py +226 -2
- agent_runtime_core/privacy.py +250 -0
- agent_runtime_core/tool_calling_agent.py +3 -1
- {agent_runtime_core-0.8.0.dist-info → agent_runtime_core-0.9.1.dist-info}/METADATA +2 -1
- {agent_runtime_core-0.8.0.dist-info → agent_runtime_core-0.9.1.dist-info}/RECORD +18 -17
- agent_runtime_core-0.9.1.dist-info/licenses/LICENSE +83 -0
- agent_runtime_core-0.8.0.dist-info/licenses/LICENSE +0 -21
- {agent_runtime_core-0.8.0.dist-info → agent_runtime_core-0.9.1.dist-info}/WHEEL +0 -0
agent_runtime_core/__init__.py
CHANGED
|
@@ -34,7 +34,7 @@ Example usage:
|
|
|
34
34
|
return RunResult(final_output={"message": "Hello!"})
|
|
35
35
|
"""
|
|
36
36
|
|
|
37
|
-
__version__ = "0.
|
|
37
|
+
__version__ = "0.9.0"
|
|
38
38
|
|
|
39
39
|
# Core interfaces
|
|
40
40
|
from agent_runtime_core.interfaces import (
|
|
@@ -63,6 +63,7 @@ from agent_runtime_core.tool_calling_agent import ToolCallingAgent
|
|
|
63
63
|
from agent_runtime_core.agentic_loop import (
|
|
64
64
|
run_agentic_loop,
|
|
65
65
|
AgenticLoopResult,
|
|
66
|
+
UsageStats,
|
|
66
67
|
)
|
|
67
68
|
|
|
68
69
|
# Configuration
|
|
@@ -187,17 +188,49 @@ from agent_runtime_core.tools import (
|
|
|
187
188
|
schemas_to_openai_format,
|
|
188
189
|
)
|
|
189
190
|
|
|
190
|
-
# Multi-agent support (agent-as-tool pattern)
|
|
191
|
+
# Multi-agent support (agent-as-tool pattern, system context)
|
|
191
192
|
from agent_runtime_core.multi_agent import (
|
|
193
|
+
# System context for shared knowledge
|
|
194
|
+
SystemContext,
|
|
195
|
+
SharedKnowledge,
|
|
196
|
+
SharedMemoryConfig,
|
|
197
|
+
InjectMode,
|
|
198
|
+
# Agent-as-tool pattern
|
|
192
199
|
AgentTool,
|
|
193
200
|
AgentInvocationResult,
|
|
194
201
|
InvocationMode,
|
|
195
202
|
ContextMode,
|
|
196
203
|
SubAgentContext,
|
|
197
204
|
invoke_agent,
|
|
205
|
+
invoke_agent_with_fallback,
|
|
198
206
|
create_agent_tool_handler,
|
|
199
207
|
register_agent_tools,
|
|
200
208
|
build_sub_agent_messages,
|
|
209
|
+
# Structured Handback Protocol
|
|
210
|
+
HandbackStatus,
|
|
211
|
+
HandbackResult,
|
|
212
|
+
Learning,
|
|
213
|
+
# Stuck/Loop Detection
|
|
214
|
+
StuckCondition,
|
|
215
|
+
StuckDetectionResult,
|
|
216
|
+
StuckDetector,
|
|
217
|
+
# Journey Mode
|
|
218
|
+
JourneyState,
|
|
219
|
+
JourneyEndReason,
|
|
220
|
+
JourneyEndResult,
|
|
221
|
+
JourneyManager,
|
|
222
|
+
JOURNEY_STATE_KEY,
|
|
223
|
+
# Fallback Routing
|
|
224
|
+
FallbackConfig,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Privacy and user isolation
|
|
228
|
+
from agent_runtime_core.privacy import (
|
|
229
|
+
PrivacyConfig,
|
|
230
|
+
UserContext,
|
|
231
|
+
MemoryScope,
|
|
232
|
+
DEFAULT_PRIVACY_CONFIG,
|
|
233
|
+
ANONYMOUS_USER,
|
|
201
234
|
)
|
|
202
235
|
|
|
203
236
|
# Cross-conversation memory
|
|
@@ -230,6 +263,7 @@ __all__ = [
|
|
|
230
263
|
"ToolCallingAgent",
|
|
231
264
|
"run_agentic_loop",
|
|
232
265
|
"AgenticLoopResult",
|
|
266
|
+
"UsageStats",
|
|
233
267
|
# Configuration
|
|
234
268
|
"RuntimeConfig",
|
|
235
269
|
"configure",
|
|
@@ -306,14 +340,42 @@ __all__ = [
|
|
|
306
340
|
"ToolSchemaBuilder",
|
|
307
341
|
"ToolParameter",
|
|
308
342
|
"schemas_to_openai_format",
|
|
309
|
-
# Multi-agent support
|
|
343
|
+
# Multi-agent support - System context
|
|
344
|
+
"SystemContext",
|
|
345
|
+
"SharedKnowledge",
|
|
346
|
+
"SharedMemoryConfig",
|
|
347
|
+
"InjectMode",
|
|
348
|
+
# Multi-agent support - Agent-as-tool
|
|
310
349
|
"AgentTool",
|
|
311
350
|
"AgentInvocationResult",
|
|
312
351
|
"InvocationMode",
|
|
313
352
|
"ContextMode",
|
|
314
353
|
"SubAgentContext",
|
|
315
354
|
"invoke_agent",
|
|
355
|
+
"invoke_agent_with_fallback",
|
|
316
356
|
"create_agent_tool_handler",
|
|
317
357
|
"register_agent_tools",
|
|
318
358
|
"build_sub_agent_messages",
|
|
359
|
+
# Multi-agent support - Structured Handback Protocol
|
|
360
|
+
"HandbackStatus",
|
|
361
|
+
"HandbackResult",
|
|
362
|
+
"Learning",
|
|
363
|
+
# Multi-agent support - Stuck/Loop Detection
|
|
364
|
+
"StuckCondition",
|
|
365
|
+
"StuckDetectionResult",
|
|
366
|
+
"StuckDetector",
|
|
367
|
+
# Multi-agent support - Journey Mode
|
|
368
|
+
"JourneyState",
|
|
369
|
+
"JourneyEndReason",
|
|
370
|
+
"JourneyEndResult",
|
|
371
|
+
"JourneyManager",
|
|
372
|
+
"JOURNEY_STATE_KEY",
|
|
373
|
+
# Multi-agent support - Fallback Routing
|
|
374
|
+
"FallbackConfig",
|
|
375
|
+
# Privacy and user isolation
|
|
376
|
+
"PrivacyConfig",
|
|
377
|
+
"UserContext",
|
|
378
|
+
"MemoryScope",
|
|
379
|
+
"DEFAULT_PRIVACY_CONFIG",
|
|
380
|
+
"ANONYMOUS_USER",
|
|
319
381
|
]
|
|
@@ -12,7 +12,7 @@ This can be used by any agent implementation without requiring inheritance.
|
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
14
|
import logging
|
|
15
|
-
from dataclasses import dataclass
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
16
|
from typing import Any, Callable, Optional, Awaitable, Union
|
|
17
17
|
|
|
18
18
|
from agent_runtime_core.interfaces import (
|
|
@@ -21,9 +21,107 @@ from agent_runtime_core.interfaces import (
|
|
|
21
21
|
LLMClient,
|
|
22
22
|
LLMResponse,
|
|
23
23
|
)
|
|
24
|
+
from agent_runtime_core.config import get_config
|
|
24
25
|
|
|
25
26
|
logger = logging.getLogger(__name__)
|
|
26
27
|
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# Cost Estimation Configuration
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
# Pricing per 1M tokens (input/output) - updated Jan 2026
|
|
34
|
+
# These are approximate and should be updated as pricing changes
|
|
35
|
+
MODEL_PRICING = {
|
|
36
|
+
# OpenAI
|
|
37
|
+
"gpt-4o": {"input": 2.50, "output": 10.00},
|
|
38
|
+
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
|
39
|
+
"gpt-4-turbo": {"input": 10.00, "output": 30.00},
|
|
40
|
+
"gpt-4": {"input": 30.00, "output": 60.00},
|
|
41
|
+
"gpt-3.5-turbo": {"input": 0.50, "output": 1.50},
|
|
42
|
+
"o1": {"input": 15.00, "output": 60.00},
|
|
43
|
+
"o1-mini": {"input": 3.00, "output": 12.00},
|
|
44
|
+
"o1-preview": {"input": 15.00, "output": 60.00},
|
|
45
|
+
"o3-mini": {"input": 1.10, "output": 4.40},
|
|
46
|
+
# Anthropic
|
|
47
|
+
"claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
|
|
48
|
+
"claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00},
|
|
49
|
+
"claude-3-opus-20240229": {"input": 15.00, "output": 75.00},
|
|
50
|
+
"claude-3-sonnet-20240229": {"input": 3.00, "output": 15.00},
|
|
51
|
+
"claude-3-haiku-20240307": {"input": 0.25, "output": 1.25},
|
|
52
|
+
# Google
|
|
53
|
+
"gemini-1.5-pro": {"input": 1.25, "output": 5.00},
|
|
54
|
+
"gemini-1.5-flash": {"input": 0.075, "output": 0.30},
|
|
55
|
+
"gemini-2.0-flash": {"input": 0.10, "output": 0.40},
|
|
56
|
+
# Default fallback
|
|
57
|
+
"default": {"input": 3.00, "output": 15.00},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_model_pricing(model: str) -> dict:
|
|
62
|
+
"""Get pricing for a model, with fallback to default."""
|
|
63
|
+
# Try exact match first
|
|
64
|
+
if model in MODEL_PRICING:
|
|
65
|
+
return MODEL_PRICING[model]
|
|
66
|
+
# Try prefix match (e.g., "gpt-4o-2024-08-06" -> "gpt-4o")
|
|
67
|
+
for key in MODEL_PRICING:
|
|
68
|
+
if model.startswith(key):
|
|
69
|
+
return MODEL_PRICING[key]
|
|
70
|
+
return MODEL_PRICING["default"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _estimate_cost(usage: dict, model: str) -> float:
|
|
74
|
+
"""Estimate cost in USD from usage dict and model."""
|
|
75
|
+
pricing = _get_model_pricing(model)
|
|
76
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
77
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
78
|
+
|
|
79
|
+
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
|
|
80
|
+
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
|
|
81
|
+
|
|
82
|
+
return input_cost + output_cost
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format_cost(cost: float) -> str:
|
|
86
|
+
"""Format cost for display."""
|
|
87
|
+
if cost < 0.01:
|
|
88
|
+
return f"${cost:.4f}"
|
|
89
|
+
return f"${cost:.3f}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class UsageStats:
|
|
94
|
+
"""Accumulated usage statistics for the agentic loop."""
|
|
95
|
+
|
|
96
|
+
total_prompt_tokens: int = 0
|
|
97
|
+
total_completion_tokens: int = 0
|
|
98
|
+
total_cost: float = 0.0
|
|
99
|
+
llm_calls: int = 0
|
|
100
|
+
tool_calls: int = 0
|
|
101
|
+
|
|
102
|
+
def add_llm_call(self, usage: dict, model: str):
|
|
103
|
+
"""Add usage from an LLM call."""
|
|
104
|
+
self.llm_calls += 1
|
|
105
|
+
self.total_prompt_tokens += usage.get("prompt_tokens", 0)
|
|
106
|
+
self.total_completion_tokens += usage.get("completion_tokens", 0)
|
|
107
|
+
self.total_cost += _estimate_cost(usage, model)
|
|
108
|
+
|
|
109
|
+
def add_tool_call(self):
|
|
110
|
+
"""Record a tool call."""
|
|
111
|
+
self.tool_calls += 1
|
|
112
|
+
|
|
113
|
+
def to_dict(self) -> dict:
|
|
114
|
+
"""Convert to dictionary."""
|
|
115
|
+
return {
|
|
116
|
+
"total_prompt_tokens": self.total_prompt_tokens,
|
|
117
|
+
"total_completion_tokens": self.total_completion_tokens,
|
|
118
|
+
"total_tokens": self.total_prompt_tokens + self.total_completion_tokens,
|
|
119
|
+
"total_cost_usd": self.total_cost,
|
|
120
|
+
"llm_calls": self.llm_calls,
|
|
121
|
+
"tool_calls": self.tool_calls,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
27
125
|
# Type alias for tool executor function
|
|
28
126
|
ToolExecutor = Callable[[str, dict], Awaitable[Any]]
|
|
29
127
|
|
|
@@ -31,19 +129,22 @@ ToolExecutor = Callable[[str, dict], Awaitable[Any]]
|
|
|
31
129
|
@dataclass
|
|
32
130
|
class AgenticLoopResult:
|
|
33
131
|
"""Result from running the agentic loop."""
|
|
34
|
-
|
|
132
|
+
|
|
35
133
|
final_content: str
|
|
36
134
|
"""The final text response from the LLM."""
|
|
37
|
-
|
|
135
|
+
|
|
38
136
|
messages: list[dict]
|
|
39
137
|
"""All messages including tool calls and results."""
|
|
40
|
-
|
|
138
|
+
|
|
41
139
|
iterations: int
|
|
42
140
|
"""Number of iterations the loop ran."""
|
|
43
|
-
|
|
141
|
+
|
|
44
142
|
usage: dict
|
|
45
143
|
"""Token usage from the final LLM call."""
|
|
46
144
|
|
|
145
|
+
usage_stats: Optional[UsageStats] = None
|
|
146
|
+
"""Accumulated usage statistics across all LLM calls (if debug mode enabled)."""
|
|
147
|
+
|
|
47
148
|
|
|
48
149
|
async def run_agentic_loop(
|
|
49
150
|
llm: LLMClient,
|
|
@@ -53,19 +154,20 @@ async def run_agentic_loop(
|
|
|
53
154
|
ctx: RunContext,
|
|
54
155
|
*,
|
|
55
156
|
model: Optional[str] = None,
|
|
56
|
-
max_iterations: int =
|
|
157
|
+
max_iterations: Optional[int] = None,
|
|
57
158
|
emit_events: bool = True,
|
|
159
|
+
ensure_final_response: bool = False,
|
|
58
160
|
**llm_kwargs,
|
|
59
161
|
) -> AgenticLoopResult:
|
|
60
162
|
"""
|
|
61
163
|
Run the standard agentic tool-calling loop.
|
|
62
|
-
|
|
164
|
+
|
|
63
165
|
This handles the common pattern of:
|
|
64
166
|
1. Call LLM with available tools
|
|
65
167
|
2. If LLM returns tool calls, execute them
|
|
66
168
|
3. Add tool results to messages and loop back to step 1
|
|
67
169
|
4. If LLM returns a text response (no tool calls), return it
|
|
68
|
-
|
|
170
|
+
|
|
69
171
|
Args:
|
|
70
172
|
llm: The LLM client to use for generation
|
|
71
173
|
messages: Initial messages (should include system prompt)
|
|
@@ -73,19 +175,23 @@ async def run_agentic_loop(
|
|
|
73
175
|
execute_tool: Async function that executes a tool: (name, args) -> result
|
|
74
176
|
ctx: Run context for emitting events
|
|
75
177
|
model: Model to use (passed to LLM client)
|
|
76
|
-
max_iterations: Maximum loop iterations to prevent infinite loops
|
|
178
|
+
max_iterations: Maximum loop iterations to prevent infinite loops.
|
|
179
|
+
If None, uses the value from config (default: 50).
|
|
77
180
|
emit_events: Whether to emit TOOL_CALL and TOOL_RESULT events
|
|
181
|
+
ensure_final_response: If True, ensures a summary is generated when tools
|
|
182
|
+
were used but the final response is empty or very short. This is useful
|
|
183
|
+
for agents that should always provide a summary of what was accomplished.
|
|
78
184
|
**llm_kwargs: Additional kwargs passed to llm.generate()
|
|
79
|
-
|
|
185
|
+
|
|
80
186
|
Returns:
|
|
81
187
|
AgenticLoopResult with final content, messages, and metadata
|
|
82
|
-
|
|
188
|
+
|
|
83
189
|
Example:
|
|
84
190
|
async def my_tool_executor(name: str, args: dict) -> Any:
|
|
85
191
|
if name == "get_weather":
|
|
86
192
|
return {"temp": 72, "conditions": "sunny"}
|
|
87
193
|
raise ValueError(f"Unknown tool: {name}")
|
|
88
|
-
|
|
194
|
+
|
|
89
195
|
result = await run_agentic_loop(
|
|
90
196
|
llm=my_llm_client,
|
|
91
197
|
messages=[{"role": "system", "content": "You are helpful."}],
|
|
@@ -93,6 +199,7 @@ async def run_agentic_loop(
|
|
|
93
199
|
execute_tool=my_tool_executor,
|
|
94
200
|
ctx=ctx,
|
|
95
201
|
model="gpt-4o",
|
|
202
|
+
ensure_final_response=True, # Guarantees a summary
|
|
96
203
|
)
|
|
97
204
|
"""
|
|
98
205
|
iteration = 0
|
|
@@ -101,10 +208,19 @@ async def run_agentic_loop(
|
|
|
101
208
|
consecutive_errors = 0
|
|
102
209
|
max_consecutive_errors = 3 # Bail out if tool keeps failing
|
|
103
210
|
|
|
104
|
-
|
|
211
|
+
# Initialize usage tracking (enabled in debug mode)
|
|
212
|
+
config = get_config()
|
|
213
|
+
debug_mode = config.debug
|
|
214
|
+
usage_stats = UsageStats() if debug_mode else None
|
|
215
|
+
effective_model = model or "unknown"
|
|
216
|
+
|
|
217
|
+
# Use config default if max_iterations not specified
|
|
218
|
+
effective_max_iterations = max_iterations if max_iterations is not None else config.max_iterations
|
|
219
|
+
|
|
220
|
+
while iteration < effective_max_iterations:
|
|
105
221
|
iteration += 1
|
|
106
|
-
print(f"[agentic-loop] Iteration {iteration}/{
|
|
107
|
-
logger.debug(f"Agentic loop iteration {iteration}/{
|
|
222
|
+
print(f"[agentic-loop] Iteration {iteration}/{effective_max_iterations}, messages={len(messages)}", flush=True)
|
|
223
|
+
logger.debug(f"Agentic loop iteration {iteration}/{effective_max_iterations}")
|
|
108
224
|
|
|
109
225
|
# Call LLM
|
|
110
226
|
if tools:
|
|
@@ -120,8 +236,25 @@ async def run_agentic_loop(
|
|
|
120
236
|
model=model,
|
|
121
237
|
**llm_kwargs,
|
|
122
238
|
)
|
|
123
|
-
|
|
239
|
+
|
|
124
240
|
last_response = response
|
|
241
|
+
|
|
242
|
+
# Track usage in debug mode
|
|
243
|
+
if debug_mode and usage_stats:
|
|
244
|
+
# Get model from response if available, otherwise use effective_model
|
|
245
|
+
resp_model = response.model or effective_model
|
|
246
|
+
usage_stats.add_llm_call(response.usage, resp_model)
|
|
247
|
+
|
|
248
|
+
# Print debug info
|
|
249
|
+
prompt_tokens = response.usage.get("prompt_tokens", 0)
|
|
250
|
+
completion_tokens = response.usage.get("completion_tokens", 0)
|
|
251
|
+
call_cost = _estimate_cost(response.usage, resp_model)
|
|
252
|
+
|
|
253
|
+
print(f"[agentic-loop] 💰 LLM Call #{usage_stats.llm_calls}:", flush=True)
|
|
254
|
+
print(f"[agentic-loop] Model: {resp_model}", flush=True)
|
|
255
|
+
print(f"[agentic-loop] Tokens: {prompt_tokens:,} in / {completion_tokens:,} out", flush=True)
|
|
256
|
+
print(f"[agentic-loop] Cost: {_format_cost(call_cost)}", flush=True)
|
|
257
|
+
print(f"[agentic-loop] Running total: {usage_stats.total_prompt_tokens:,} in / {usage_stats.total_completion_tokens:,} out = {_format_cost(usage_stats.total_cost)}", flush=True)
|
|
125
258
|
|
|
126
259
|
# Check for tool calls
|
|
127
260
|
if response.tool_calls:
|
|
@@ -158,6 +291,11 @@ async def run_agentic_loop(
|
|
|
158
291
|
logger.warning(f"Failed to parse tool args: {tool_args_str}")
|
|
159
292
|
tool_args = {}
|
|
160
293
|
|
|
294
|
+
# Track tool call in debug mode
|
|
295
|
+
if debug_mode and usage_stats:
|
|
296
|
+
usage_stats.add_tool_call()
|
|
297
|
+
print(f"[agentic-loop] 🔧 Tool #{usage_stats.tool_calls}: {tool_name}", flush=True)
|
|
298
|
+
|
|
161
299
|
# Emit tool call event
|
|
162
300
|
if emit_events:
|
|
163
301
|
await ctx.emit(EventType.TOOL_CALL, {
|
|
@@ -165,7 +303,7 @@ async def run_agentic_loop(
|
|
|
165
303
|
"name": tool_name,
|
|
166
304
|
"arguments": tool_args,
|
|
167
305
|
})
|
|
168
|
-
|
|
306
|
+
|
|
169
307
|
# Execute the tool
|
|
170
308
|
try:
|
|
171
309
|
result = await execute_tool(tool_name, tool_args)
|
|
@@ -209,8 +347,18 @@ async def run_agentic_loop(
|
|
|
209
347
|
"iterations": iteration,
|
|
210
348
|
})
|
|
211
349
|
|
|
212
|
-
#
|
|
213
|
-
|
|
350
|
+
# Generate a summary if ensure_final_response is enabled
|
|
351
|
+
if ensure_final_response:
|
|
352
|
+
logger.info("Generating summary after error exit because ensure_final_response=True")
|
|
353
|
+
print("[agentic-loop] Generating summary after error exit", flush=True)
|
|
354
|
+
summary = await _generate_task_summary(llm, messages, model, **llm_kwargs)
|
|
355
|
+
if summary:
|
|
356
|
+
final_content = f"{summary}\n\n---\n\n⚠️ Note: The task ended early due to repeated errors. Last error: {error_msg}"
|
|
357
|
+
else:
|
|
358
|
+
final_content = f"I encountered repeated errors while trying to complete this task. The last error was: {error_msg}"
|
|
359
|
+
else:
|
|
360
|
+
final_content = f"I encountered repeated errors while trying to complete this task. The last error was: {error_msg}"
|
|
361
|
+
|
|
214
362
|
messages.append({
|
|
215
363
|
"role": "assistant",
|
|
216
364
|
"content": final_content,
|
|
@@ -222,11 +370,22 @@ async def run_agentic_loop(
|
|
|
222
370
|
"role": "assistant",
|
|
223
371
|
})
|
|
224
372
|
|
|
373
|
+
# Print final summary in debug mode
|
|
374
|
+
if debug_mode and usage_stats:
|
|
375
|
+
print(f"[agentic-loop] ═══════════════════════════════════════════", flush=True)
|
|
376
|
+
print(f"[agentic-loop] 📊 FINAL USAGE SUMMARY (error exit)", flush=True)
|
|
377
|
+
print(f"[agentic-loop] LLM calls: {usage_stats.llm_calls}", flush=True)
|
|
378
|
+
print(f"[agentic-loop] Tool calls: {usage_stats.tool_calls}", flush=True)
|
|
379
|
+
print(f"[agentic-loop] Total tokens: {usage_stats.total_prompt_tokens:,} in / {usage_stats.total_completion_tokens:,} out", flush=True)
|
|
380
|
+
print(f"[agentic-loop] Estimated cost: {_format_cost(usage_stats.total_cost)}", flush=True)
|
|
381
|
+
print(f"[agentic-loop] ═══════════════════════════════════════════", flush=True)
|
|
382
|
+
|
|
225
383
|
return AgenticLoopResult(
|
|
226
384
|
final_content=final_content,
|
|
227
385
|
messages=messages,
|
|
228
386
|
iterations=iteration,
|
|
229
387
|
usage=last_response.usage if last_response else {},
|
|
388
|
+
usage_stats=usage_stats,
|
|
230
389
|
)
|
|
231
390
|
|
|
232
391
|
# Continue the loop to get next response
|
|
@@ -244,11 +403,117 @@ async def run_agentic_loop(
|
|
|
244
403
|
})
|
|
245
404
|
|
|
246
405
|
break
|
|
247
|
-
|
|
406
|
+
|
|
407
|
+
# Check if we need to ensure a final response (summary)
|
|
408
|
+
if ensure_final_response:
|
|
409
|
+
# Check if tools were used during this run
|
|
410
|
+
tools_were_used = any(
|
|
411
|
+
msg.get("role") == "assistant" and msg.get("tool_calls")
|
|
412
|
+
for msg in messages
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# If tools were used but final response is empty or very short, generate a summary
|
|
416
|
+
if tools_were_used and (not final_content or len(final_content.strip()) < 50):
|
|
417
|
+
logger.info("Generating summary because tools were used but final response was empty/short")
|
|
418
|
+
print("[agentic-loop] Generating summary - tools were used but no final response", flush=True)
|
|
419
|
+
|
|
420
|
+
summary = await _generate_task_summary(llm, messages, model, **llm_kwargs)
|
|
421
|
+
if summary:
|
|
422
|
+
final_content = summary
|
|
423
|
+
# Emit the summary as an assistant message
|
|
424
|
+
if emit_events:
|
|
425
|
+
await ctx.emit(EventType.ASSISTANT_MESSAGE, {
|
|
426
|
+
"content": summary,
|
|
427
|
+
"role": "assistant",
|
|
428
|
+
})
|
|
429
|
+
# Add to messages
|
|
430
|
+
messages.append({"role": "assistant", "content": summary})
|
|
431
|
+
|
|
432
|
+
# Print final summary in debug mode
|
|
433
|
+
if debug_mode and usage_stats:
|
|
434
|
+
print(f"[agentic-loop] ═══════════════════════════════════════════", flush=True)
|
|
435
|
+
print(f"[agentic-loop] 📊 FINAL USAGE SUMMARY", flush=True)
|
|
436
|
+
print(f"[agentic-loop] Iterations: {iteration}", flush=True)
|
|
437
|
+
print(f"[agentic-loop] LLM calls: {usage_stats.llm_calls}", flush=True)
|
|
438
|
+
print(f"[agentic-loop] Tool calls: {usage_stats.tool_calls}", flush=True)
|
|
439
|
+
print(f"[agentic-loop] Total tokens: {usage_stats.total_prompt_tokens:,} in / {usage_stats.total_completion_tokens:,} out", flush=True)
|
|
440
|
+
print(f"[agentic-loop] Estimated cost: {_format_cost(usage_stats.total_cost)}", flush=True)
|
|
441
|
+
print(f"[agentic-loop] ═══════════════════════════════════════════", flush=True)
|
|
442
|
+
|
|
248
443
|
return AgenticLoopResult(
|
|
249
444
|
final_content=final_content,
|
|
250
445
|
messages=messages,
|
|
251
446
|
iterations=iteration,
|
|
252
447
|
usage=last_response.usage if last_response else {},
|
|
448
|
+
usage_stats=usage_stats,
|
|
253
449
|
)
|
|
254
450
|
|
|
451
|
+
|
|
452
|
+
async def _generate_task_summary(
|
|
453
|
+
llm: LLMClient,
|
|
454
|
+
messages: list[dict],
|
|
455
|
+
model: Optional[str] = None,
|
|
456
|
+
**llm_kwargs,
|
|
457
|
+
) -> str:
|
|
458
|
+
"""
|
|
459
|
+
Generate a summary of what was accomplished based on the conversation history.
|
|
460
|
+
|
|
461
|
+
This is called when ensure_final_response=True and tools were used but
|
|
462
|
+
no meaningful final response was provided.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
llm: The LLM client to use
|
|
466
|
+
messages: The conversation history including tool calls and results
|
|
467
|
+
model: Model to use
|
|
468
|
+
**llm_kwargs: Additional kwargs for the LLM
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
A summary string of what was accomplished
|
|
472
|
+
"""
|
|
473
|
+
# Build a summary of tool calls and their results
|
|
474
|
+
tool_summary_parts = []
|
|
475
|
+
for msg in messages:
|
|
476
|
+
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
477
|
+
for tc in msg.get("tool_calls", []):
|
|
478
|
+
if isinstance(tc, dict):
|
|
479
|
+
name = tc.get("function", {}).get("name", "unknown")
|
|
480
|
+
else:
|
|
481
|
+
name = getattr(tc, "name", "unknown")
|
|
482
|
+
tool_summary_parts.append(f"- Called: {name}")
|
|
483
|
+
elif msg.get("role") == "tool":
|
|
484
|
+
content = msg.get("content", "")
|
|
485
|
+
# Truncate long results
|
|
486
|
+
if len(content) > 200:
|
|
487
|
+
content = content[:200] + "..."
|
|
488
|
+
tool_summary_parts.append(f" Result: {content}")
|
|
489
|
+
|
|
490
|
+
tool_summary = "\n".join(tool_summary_parts[-20:]) # Last 20 entries to avoid token limits
|
|
491
|
+
|
|
492
|
+
summary_prompt = f"""Based on the conversation above, provide a brief summary of what was accomplished.
|
|
493
|
+
|
|
494
|
+
Here's a summary of the tools that were called:
|
|
495
|
+
{tool_summary}
|
|
496
|
+
|
|
497
|
+
Please provide a clear, concise summary (2-4 sentences) of:
|
|
498
|
+
1. What actions were taken
|
|
499
|
+
2. What was accomplished or changed
|
|
500
|
+
3. Any important results or next steps
|
|
501
|
+
|
|
502
|
+
Start your response directly with the summary - do not include phrases like "Here's a summary" or "Based on the conversation"."""
|
|
503
|
+
|
|
504
|
+
# Create a simplified message list for the summary request
|
|
505
|
+
summary_messages = [
|
|
506
|
+
{"role": "system", "content": "You are a helpful assistant that provides clear, concise summaries of completed tasks."},
|
|
507
|
+
{"role": "user", "content": summary_prompt},
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
response = await llm.generate(
|
|
512
|
+
summary_messages,
|
|
513
|
+
model=model,
|
|
514
|
+
**llm_kwargs,
|
|
515
|
+
)
|
|
516
|
+
return response.message.get("content", "")
|
|
517
|
+
except Exception as e:
|
|
518
|
+
logger.exception("Failed to generate task summary")
|
|
519
|
+
return f"Task completed. (Summary generation failed: {e})"
|
agent_runtime_core/config.py
CHANGED
|
@@ -88,6 +88,12 @@ class RuntimeConfig:
|
|
|
88
88
|
vertex_deployed_index_id: Optional[str] = None
|
|
89
89
|
vertex_index_id: Optional[str] = None
|
|
90
90
|
|
|
91
|
+
# Debug mode - enables verbose logging, cost tracking, etc.
|
|
92
|
+
debug: bool = False
|
|
93
|
+
|
|
94
|
+
# Agentic loop settings
|
|
95
|
+
max_iterations: int = 50 # Maximum iterations for tool-calling loops
|
|
96
|
+
|
|
91
97
|
def get_openai_api_key(self) -> Optional[str]:
|
|
92
98
|
"""Get OpenAI API key from config or environment."""
|
|
93
99
|
return self.openai_api_key or os.environ.get("OPENAI_API_KEY")
|
|
@@ -199,11 +205,13 @@ def _apply_env_vars(config: RuntimeConfig) -> None:
|
|
|
199
205
|
"AGENT_RUNTIME_RETRY_BACKOFF_BASE": "retry_backoff_base",
|
|
200
206
|
"AGENT_RUNTIME_RETRY_BACKOFF_MAX": "retry_backoff_max",
|
|
201
207
|
"AGENT_RUNTIME_MAX_HISTORY_MESSAGES": "max_history_messages",
|
|
208
|
+
"AGENT_RUNTIME_MAX_ITERATIONS": "max_iterations",
|
|
202
209
|
}
|
|
203
210
|
|
|
204
211
|
bool_fields = {
|
|
205
212
|
"AGENT_RUNTIME_INCLUDE_CONVERSATION_HISTORY": "include_conversation_history",
|
|
206
213
|
"AGENT_RUNTIME_AUTO_PERSIST_MESSAGES": "auto_persist_messages",
|
|
214
|
+
"AGENT_RUNTIME_DEBUG": "debug",
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
for env_var, attr in env_mapping.items():
|