roboz 0.1.2.dev2__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.
- roboz/__init__.py +91 -0
- roboz/_naming.py +19 -0
- roboz/agent/__init__.py +21 -0
- roboz/agent/_execution_context.py +53 -0
- roboz/agent/_identifiers.py +15 -0
- roboz/agent/_notifications.py +25 -0
- roboz/agent/_prompts.py +154 -0
- roboz/agent/_tool_observer.py +159 -0
- roboz/agent/background_agent.py +157 -0
- roboz/agent/core.py +769 -0
- roboz/agent/prompt_agent_tool.py +37 -0
- roboz/agent/subagent.py +28 -0
- roboz/dependencies.py +297 -0
- roboz/deployment.py +151 -0
- roboz/exceptions.py +115 -0
- roboz/llm/__init__.py +56 -0
- roboz/llm/_diagnostics.py +521 -0
- roboz/llm/_retry.py +118 -0
- roboz/llm/_truncation.py +125 -0
- roboz/llm/binding.py +150 -0
- roboz/llm/calls.py +760 -0
- roboz/llm/completion.py +246 -0
- roboz/llm/endpoints.py +296 -0
- roboz/llm/openrouter.py +107 -0
- roboz/llm/prompts.py +79 -0
- roboz/models/__init__.py +61 -0
- roboz/models/_schema.py +82 -0
- roboz/models/_serialization.py +60 -0
- roboz/models/_telemetry.py +26 -0
- roboz/models/core.py +155 -0
- roboz/models/truncation.py +68 -0
- roboz/py.typed +1 -0
- roboz/runtime/__init__.py +82 -0
- roboz/runtime/_environment.py +19 -0
- roboz/runtime/_external.py +340 -0
- roboz/runtime/_logging.py +87 -0
- roboz/runtime/_paths.py +60 -0
- roboz/runtime/events.py +84 -0
- roboz/runtime/io.py +108 -0
- roboz/runtime/observability.py +108 -0
- roboz/runtime/persistence/__init__.py +39 -0
- roboz/runtime/persistence/activity.py +106 -0
- roboz/runtime/persistence/schema.py +155 -0
- roboz/runtime/pipe.py +384 -0
- roboz/runtime/sinks.py +277 -0
- roboz/skill/__init__.py +5 -0
- roboz/skill/_prompts.py +50 -0
- roboz/skill/core.py +140 -0
- roboz/tooling/__init__.py +6 -0
- roboz/tooling/_prompts.py +124 -0
- roboz/tooling/_protocols.py +61 -0
- roboz/tooling/_typing.py +20 -0
- roboz/tooling/context.py +101 -0
- roboz/tooling/core.py +289 -0
- roboz/tooling/decorators.py +236 -0
- roboz/tools/__init__.py +20 -0
- roboz/tools/_identifiers.py +8 -0
- roboz/tools/control.py +54 -0
- roboz/tools/interaction.py +68 -0
- roboz-0.1.2.dev2.dist-info/METADATA +215 -0
- roboz-0.1.2.dev2.dist-info/RECORD +63 -0
- roboz-0.1.2.dev2.dist-info/WHEEL +4 -0
- roboz-0.1.2.dev2.dist-info/licenses/LICENSE +201 -0
roboz/agent/core.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Agent composition and synchronous tool-execution runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal, Sequence, TypedDict, Unpack
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from roboz.agent._execution_context import (
|
|
13
|
+
get_active_agent_stack,
|
|
14
|
+
pop_active_agent,
|
|
15
|
+
push_active_agent,
|
|
16
|
+
validate_agent_name,
|
|
17
|
+
)
|
|
18
|
+
from roboz.agent._notifications import (
|
|
19
|
+
AUTO_LOAD_SKILLS_BANNER,
|
|
20
|
+
INTERRUPT_PROMPT_TO_USER,
|
|
21
|
+
INTERRUPTED_GENERATION_CONTEXT,
|
|
22
|
+
LLM_PROVIDER_REQUEST_RETRY_PROMPT,
|
|
23
|
+
auto_load_skill_rationale,
|
|
24
|
+
)
|
|
25
|
+
from roboz.agent._prompts import get_agentic_system_prompt
|
|
26
|
+
from roboz.agent._tool_observer import ToolInvocationObserver
|
|
27
|
+
from roboz.agent.prompt_agent_tool import prompt_agent
|
|
28
|
+
from roboz.exceptions import (
|
|
29
|
+
ExternalCallCancelledError,
|
|
30
|
+
ExternalCallInterruptedError,
|
|
31
|
+
LLMProviderRequestError,
|
|
32
|
+
StopAgent,
|
|
33
|
+
)
|
|
34
|
+
from roboz.llm.binding import resolve_endpoint
|
|
35
|
+
from roboz.llm.endpoints import EndpointLike, LLMEndpoint, MockLLMEndpoint
|
|
36
|
+
from roboz.models import Empty, Invoke, Message, MessageKind, Role, Stop, Str
|
|
37
|
+
from roboz.models._schema import get_constituent_types
|
|
38
|
+
from roboz.models._serialization import get_finalized_message
|
|
39
|
+
from roboz.runtime.events import EventSink
|
|
40
|
+
from roboz.runtime.io import (
|
|
41
|
+
Output,
|
|
42
|
+
bind_output,
|
|
43
|
+
get_bound_output,
|
|
44
|
+
reset_output,
|
|
45
|
+
)
|
|
46
|
+
from roboz.runtime.persistence import RunStatus
|
|
47
|
+
from roboz.runtime.pipe import EventPipe
|
|
48
|
+
from roboz.skill.core import Skill
|
|
49
|
+
from roboz.tooling.context import Ctx
|
|
50
|
+
from roboz.tooling.core import Factory, Tool
|
|
51
|
+
from roboz.dependencies import (
|
|
52
|
+
ExternalDependency,
|
|
53
|
+
ExternalDependencyReference,
|
|
54
|
+
ExternalDependencySource,
|
|
55
|
+
dedupe_external_dependencies,
|
|
56
|
+
)
|
|
57
|
+
from roboz.tools.interaction import NO_REPLY, prompt_user
|
|
58
|
+
|
|
59
|
+
logger = getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AgentInputs(TypedDict, total=False):
|
|
63
|
+
"""Optional fields for `Agent.copy(**...)`; each key replaces the copied value."""
|
|
64
|
+
|
|
65
|
+
interaction_mode: Output | None
|
|
66
|
+
name: str
|
|
67
|
+
description: str
|
|
68
|
+
tools: Sequence[Tool]
|
|
69
|
+
skills: Sequence[Skill]
|
|
70
|
+
system_prompt: str
|
|
71
|
+
auto_loaded_skills: Sequence[Skill] | None
|
|
72
|
+
automatic_tool_prompt: bool
|
|
73
|
+
is_agentic: bool
|
|
74
|
+
default_tools: Sequence[Tool] | None
|
|
75
|
+
custom_prompt_user_tool: Tool | None
|
|
76
|
+
agent_endpoint: EndpointLike | None
|
|
77
|
+
initial_messages: Sequence[Path | str] | None
|
|
78
|
+
event_pipe: EventPipe | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Agent(ExternalDependencySource):
|
|
82
|
+
"""Agent primitive.
|
|
83
|
+
|
|
84
|
+
The agent uses the supplied :class:`~roboz.runtime.pipe.EventPipe`, or creates
|
|
85
|
+
its own per-agent pipe when one is not supplied. ``event_sinks`` is a flat
|
|
86
|
+
list of callbacks attached to a newly created pipe. Hosts compose CLI,
|
|
87
|
+
run-scoped, process-scoped, or agent-specific callbacks before creating the
|
|
88
|
+
agent; Agent does not construct or distinguish those scopes.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
name: str,
|
|
95
|
+
description: str = "",
|
|
96
|
+
interaction_mode: Output | None = Output.CLI,
|
|
97
|
+
tools: Sequence[Tool | Sequence[Tool]] | None = None,
|
|
98
|
+
skills: Sequence[Skill] | None = None,
|
|
99
|
+
system_prompt: str = "",
|
|
100
|
+
auto_loaded_skills: Sequence[Skill] | None = None,
|
|
101
|
+
automatic_tool_prompt: bool = True,
|
|
102
|
+
is_agentic: bool = True,
|
|
103
|
+
default_tools: Sequence[Tool] | None = None,
|
|
104
|
+
custom_prompt_user_tool: Tool | None = None,
|
|
105
|
+
agent_endpoint: EndpointLike | None,
|
|
106
|
+
initial_messages: Sequence[Path | str] | None = None,
|
|
107
|
+
event_sinks: Sequence[EventSink] | None = None,
|
|
108
|
+
event_pipe: EventPipe | None = None,
|
|
109
|
+
):
|
|
110
|
+
"""Initialize and validate an agent's tools, skills, endpoint, and event pipe."""
|
|
111
|
+
if event_pipe is not None and event_sinks is not None:
|
|
112
|
+
raise ValueError("pass either event_pipe or event_sinks, not both")
|
|
113
|
+
self.name = validate_agent_name(name)
|
|
114
|
+
self.description = description
|
|
115
|
+
self.tools = Tool.to_tool_list(tools)
|
|
116
|
+
self.interaction_mode = (
|
|
117
|
+
interaction_mode
|
|
118
|
+
if interaction_mode is not None
|
|
119
|
+
else get_bound_output(default=Output.CLI)
|
|
120
|
+
)
|
|
121
|
+
self.pipe = (
|
|
122
|
+
event_pipe if event_pipe is not None else EventPipe(event_sinks=event_sinks)
|
|
123
|
+
)
|
|
124
|
+
self.event_sinks = self.pipe.event_sinks
|
|
125
|
+
self._tool_invocations = ToolInvocationObserver(
|
|
126
|
+
agent_name=self.name, pipe=self.pipe
|
|
127
|
+
)
|
|
128
|
+
self.skills = skills
|
|
129
|
+
self.auto_loaded_skills = auto_loaded_skills
|
|
130
|
+
self.system_prompt = system_prompt
|
|
131
|
+
self.automatic_tool_prompt = automatic_tool_prompt
|
|
132
|
+
self.is_agentic = is_agentic
|
|
133
|
+
if self.is_agentic and not self.system_prompt.strip():
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"Agent '{self.name}' is agentic but has an empty system prompt. "
|
|
136
|
+
"Agentic flows require a non-empty system_prompt."
|
|
137
|
+
)
|
|
138
|
+
self.default_tools = [] if default_tools is None else list(default_tools)
|
|
139
|
+
self.prompt_user_tool = (
|
|
140
|
+
prompt_user(Ctx(timeout_reply=NO_REPLY))
|
|
141
|
+
if custom_prompt_user_tool is None
|
|
142
|
+
else custom_prompt_user_tool
|
|
143
|
+
)
|
|
144
|
+
if agent_endpoint is not None and not isinstance(
|
|
145
|
+
agent_endpoint, (ExternalDependency, ExternalDependencyReference, MockLLMEndpoint)
|
|
146
|
+
):
|
|
147
|
+
raise TypeError(
|
|
148
|
+
"agent_endpoint must be an endpoint dependency or MockLLMEndpoint"
|
|
149
|
+
)
|
|
150
|
+
if self.is_agentic and agent_endpoint is None:
|
|
151
|
+
raise ValueError("agentic instances require agent_endpoint")
|
|
152
|
+
self.agent_endpoint = agent_endpoint
|
|
153
|
+
self.initial_messages = initial_messages if initial_messages else []
|
|
154
|
+
|
|
155
|
+
self.active_tools: dict[str, Tool] = {}
|
|
156
|
+
self.passive_tools: dict[str, Tool] = {}
|
|
157
|
+
self.messages: list[Message] = []
|
|
158
|
+
self._skills: dict[str, Skill] = {}
|
|
159
|
+
self._auto_loaded_skills: dict[str, Skill] = {}
|
|
160
|
+
self._loaded_skills: set[Skill] = set()
|
|
161
|
+
|
|
162
|
+
# Tools
|
|
163
|
+
all_tools = self._append_tools_reducer(
|
|
164
|
+
[self.prompt_user_tool], "tool", self.tools
|
|
165
|
+
)
|
|
166
|
+
all_tools = self._append_tools_reducer(skills, "skill", all_tools)
|
|
167
|
+
all_tools = self._append_tools_reducer(auto_loaded_skills, "skill", all_tools)
|
|
168
|
+
self._init_tools(tools=all_tools, skill=None)
|
|
169
|
+
|
|
170
|
+
# Skills
|
|
171
|
+
if skills is not None:
|
|
172
|
+
self._skills = {skill.name: skill for skill in skills}
|
|
173
|
+
if auto_loaded_skills is not None:
|
|
174
|
+
self._auto_loaded_skills = {
|
|
175
|
+
skill.name: skill for skill in auto_loaded_skills
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# System Prompt
|
|
179
|
+
self.full_system_prompt = self._get_full_system_prompt(
|
|
180
|
+
self.automatic_tool_prompt
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _append_tools_reducer(
|
|
185
|
+
addition: list | Sequence | None,
|
|
186
|
+
method: Literal["skill", "tool"],
|
|
187
|
+
current_value: list,
|
|
188
|
+
) -> list[Tool]:
|
|
189
|
+
|
|
190
|
+
if method == "tool":
|
|
191
|
+
if addition is not None:
|
|
192
|
+
return current_value + [a for a in addition if a is not None]
|
|
193
|
+
return current_value
|
|
194
|
+
else:
|
|
195
|
+
if addition is not None:
|
|
196
|
+
return current_value + [s.get_skill_as_tool() for s in addition]
|
|
197
|
+
return current_value
|
|
198
|
+
|
|
199
|
+
def _get_full_system_prompt(
|
|
200
|
+
self,
|
|
201
|
+
use_generated_tool_instructions,
|
|
202
|
+
) -> str:
|
|
203
|
+
if use_generated_tool_instructions and self.system_prompt:
|
|
204
|
+
# Exclude auto-loaded skill wrappers from prompt—they're redundant
|
|
205
|
+
# since those skills are already loaded at invocation start.
|
|
206
|
+
auto_loaded_names = set(self._auto_loaded_skills)
|
|
207
|
+
tools_for_prompt = [
|
|
208
|
+
t for t in self.active_tools.values() if t.name not in auto_loaded_names
|
|
209
|
+
]
|
|
210
|
+
return get_agentic_system_prompt(
|
|
211
|
+
system_prompt=self.system_prompt,
|
|
212
|
+
tools=tools_for_prompt,
|
|
213
|
+
skills=list(self._skills.values()),
|
|
214
|
+
)
|
|
215
|
+
return self.system_prompt
|
|
216
|
+
|
|
217
|
+
def copy(self, **overrides: Unpack[AgentInputs]) -> Agent:
|
|
218
|
+
"""Copy this agent configuration while preserving its event pipe by default."""
|
|
219
|
+
old_input: AgentInputs = {
|
|
220
|
+
"interaction_mode": self.interaction_mode,
|
|
221
|
+
"name": self.name,
|
|
222
|
+
"description": self.description,
|
|
223
|
+
"tools": self.tools,
|
|
224
|
+
"skills": list(self._skills.values()),
|
|
225
|
+
"system_prompt": self.system_prompt,
|
|
226
|
+
"auto_loaded_skills": list(self._auto_loaded_skills.values()),
|
|
227
|
+
"automatic_tool_prompt": self.automatic_tool_prompt,
|
|
228
|
+
"is_agentic": self.is_agentic,
|
|
229
|
+
"default_tools": self.default_tools,
|
|
230
|
+
"custom_prompt_user_tool": self.prompt_user_tool,
|
|
231
|
+
"agent_endpoint": self.agent_endpoint,
|
|
232
|
+
"initial_messages": None
|
|
233
|
+
if not self.initial_messages
|
|
234
|
+
else self.initial_messages,
|
|
235
|
+
"event_pipe": self.pipe,
|
|
236
|
+
}
|
|
237
|
+
return Agent(**(old_input | overrides))
|
|
238
|
+
|
|
239
|
+
def _init_default_tool(self):
|
|
240
|
+
if self.is_agentic:
|
|
241
|
+
endpoint = self.agent_endpoint
|
|
242
|
+
if endpoint is None:
|
|
243
|
+
raise RuntimeError("agentic instance has no endpoint")
|
|
244
|
+
endpoint_binding = endpoint
|
|
245
|
+
self.master_tool = prompt_agent(
|
|
246
|
+
Ctx(
|
|
247
|
+
active_tools=tuple(self.active_tools.values()),
|
|
248
|
+
endpoint=endpoint_binding,
|
|
249
|
+
pipe=self.pipe,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
if not self.default_tools:
|
|
254
|
+
raise ValueError("No default tools for a non-agentic instance.")
|
|
255
|
+
self.master_tool = self.default_tools[0]
|
|
256
|
+
|
|
257
|
+
def _init_tools(self, *, tools: Sequence[Tool], skill: Skill | None):
|
|
258
|
+
for t in self.default_tools:
|
|
259
|
+
if any(
|
|
260
|
+
issubclass(output_type, Invoke)
|
|
261
|
+
for output_type in get_constituent_types(t.OutputModel)
|
|
262
|
+
):
|
|
263
|
+
raise ValueError(f"Default tool '{t.name}' cannot return Invoke")
|
|
264
|
+
|
|
265
|
+
all_tools = set(tools)
|
|
266
|
+
if skill:
|
|
267
|
+
all_tools |= set(skill.tools)
|
|
268
|
+
|
|
269
|
+
for t in all_tools:
|
|
270
|
+
if t.chained_to:
|
|
271
|
+
self.passive_tools[t.id] = t
|
|
272
|
+
continue
|
|
273
|
+
self.active_tools[t.id] = t
|
|
274
|
+
self._init_default_tool()
|
|
275
|
+
self._validate_tool_chains(all_tools | {self.master_tool})
|
|
276
|
+
|
|
277
|
+
def external_dependencies(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
include_lazy_skills: bool = True,
|
|
281
|
+
) -> tuple[ExternalDependency, ...]:
|
|
282
|
+
"""Derive the agent's dependency catalog exclusively from its Tool graph."""
|
|
283
|
+
tools: list[Tool] = [
|
|
284
|
+
self.master_tool,
|
|
285
|
+
self.prompt_user_tool,
|
|
286
|
+
*self.default_tools,
|
|
287
|
+
*self.active_tools.values(),
|
|
288
|
+
*self.passive_tools.values(),
|
|
289
|
+
]
|
|
290
|
+
if include_lazy_skills:
|
|
291
|
+
for skill in (
|
|
292
|
+
*tuple(self.skills or ()),
|
|
293
|
+
*tuple(self.auto_loaded_skills or ()),
|
|
294
|
+
):
|
|
295
|
+
tools.extend(skill.tools)
|
|
296
|
+
|
|
297
|
+
return dedupe_external_dependencies(
|
|
298
|
+
[resource for tool in tools for resource in tool.external_dependencies]
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _get_validated_chained_tool_id(
|
|
303
|
+
chained_tool: Tool | Factory, all_tools: dict[str, Tool], tool
|
|
304
|
+
) -> str:
|
|
305
|
+
ref_id = getattr(chained_tool, "id", None)
|
|
306
|
+
if ref_id not in all_tools:
|
|
307
|
+
logger.error(
|
|
308
|
+
f"Chained tool '{getattr(chained_tool, 'name', 'Unknown')}' (id: {ref_id}) "
|
|
309
|
+
f"for tool '{tool.name}' does not exist in agent tools"
|
|
310
|
+
)
|
|
311
|
+
raise ValueError
|
|
312
|
+
return ref_id
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def _validate_link(chained_tool: Tool, tool: Tool):
|
|
316
|
+
types_to_check = get_constituent_types(chained_tool.OutputModel)
|
|
317
|
+
types_to_check = [t for t in types_to_check if not issubclass(t, Stop)]
|
|
318
|
+
|
|
319
|
+
for arg in types_to_check:
|
|
320
|
+
if not issubclass(arg, Empty):
|
|
321
|
+
logger.error(
|
|
322
|
+
f"Chained tool '{chained_tool.name}' must have output that is a subclass of {Empty}, "
|
|
323
|
+
f"got '{arg.__name__}'"
|
|
324
|
+
)
|
|
325
|
+
raise ValueError
|
|
326
|
+
|
|
327
|
+
if types_to_check and not any(
|
|
328
|
+
issubclass(arg, tool.InputModel) for arg in types_to_check
|
|
329
|
+
):
|
|
330
|
+
logger.error(
|
|
331
|
+
f"Chained tool '{chained_tool.name}' must have output that is a subclass of tool input {tool.InputModel}, "
|
|
332
|
+
f"got {list(types_to_check)}"
|
|
333
|
+
)
|
|
334
|
+
raise ValueError
|
|
335
|
+
|
|
336
|
+
def _validate_tool_chains(self, tools: set[Tool]) -> None:
|
|
337
|
+
"""This does the heavy lifting for tool chains."""
|
|
338
|
+
active_names = [t.name for t in tools if not t.chained_to]
|
|
339
|
+
if len(active_names) != len(set(active_names)):
|
|
340
|
+
raise ValueError(f"Duplicate tool name {active_names=}")
|
|
341
|
+
|
|
342
|
+
all_tools: dict[str, Tool] = {}
|
|
343
|
+
for tool in tools:
|
|
344
|
+
if tool.id in all_tools:
|
|
345
|
+
logger.error(f"Duplicate tool id '{tool.id}' for {tool.name=}")
|
|
346
|
+
raise ValueError
|
|
347
|
+
all_tools[tool.id] = tool
|
|
348
|
+
|
|
349
|
+
for t in tools:
|
|
350
|
+
if t.chained_to is None:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
for chained_tool in t.chained_to:
|
|
354
|
+
chained_id = self._get_validated_chained_tool_id(
|
|
355
|
+
chained_tool, all_tools, t
|
|
356
|
+
)
|
|
357
|
+
self._validate_link(all_tools[chained_id], t)
|
|
358
|
+
|
|
359
|
+
def get_next_tool(
|
|
360
|
+
self, tool: Tool[Empty, Any] | None, output: Invoke | Empty | Stop | Stop
|
|
361
|
+
) -> Tool:
|
|
362
|
+
"""Resolve the unique active, chained, or default tool for an output."""
|
|
363
|
+
chained_tools = []
|
|
364
|
+
if isinstance(output, Invoke):
|
|
365
|
+
self._init_skill(output)
|
|
366
|
+
for t in self.active_tools.values():
|
|
367
|
+
if t.name == output.action:
|
|
368
|
+
return t
|
|
369
|
+
raise RuntimeError(f"Called tool {output.action} does not exist!")
|
|
370
|
+
|
|
371
|
+
for passive in self.passive_tools.values():
|
|
372
|
+
assert passive.chained_to is not None
|
|
373
|
+
for chained_tool in passive.chained_to:
|
|
374
|
+
if (
|
|
375
|
+
tool
|
|
376
|
+
and chained_tool.id == tool.id
|
|
377
|
+
and passive.chain_condition(output)
|
|
378
|
+
):
|
|
379
|
+
chained_tools.append(passive)
|
|
380
|
+
if len(chained_tools) == 1:
|
|
381
|
+
return chained_tools[0]
|
|
382
|
+
if len(chained_tools) == 0:
|
|
383
|
+
return self.pop_ephemeral_default_tool()
|
|
384
|
+
logger.error(f"Many True next links in {[c.name for c in chained_tools]}")
|
|
385
|
+
raise RuntimeError
|
|
386
|
+
|
|
387
|
+
def pop_ephemeral_default_tool(self):
|
|
388
|
+
"""Pop the next per-run default tool, replenishing the sequence as needed."""
|
|
389
|
+
try:
|
|
390
|
+
return self._ephemeral_default_tools.pop(0)
|
|
391
|
+
except IndexError:
|
|
392
|
+
self._ephemeral_default_tools = list(self.default_tools) + [
|
|
393
|
+
self.master_tool
|
|
394
|
+
]
|
|
395
|
+
return self._ephemeral_default_tools.pop(0)
|
|
396
|
+
|
|
397
|
+
def append_and_pipe(self, message: Message):
|
|
398
|
+
"""Append a message to conversation state and emit it through the pipe."""
|
|
399
|
+
self.messages += [message]
|
|
400
|
+
self.pipe(message)
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _get_stored_message(location: Path) -> Message | None:
|
|
404
|
+
if not location.is_dir() and location.suffix == ".md":
|
|
405
|
+
return get_finalized_message(
|
|
406
|
+
Str(value=location.read_text()),
|
|
407
|
+
message_kind=MessageKind.STARTUP_CONTEXT,
|
|
408
|
+
)
|
|
409
|
+
if location.is_dir() and list(location.glob("*.md")):
|
|
410
|
+
sorted_location = sorted(location.glob("*.md"), reverse=True)[0]
|
|
411
|
+
return get_finalized_message(
|
|
412
|
+
Str(value=sorted_location.read_text()),
|
|
413
|
+
message_kind=MessageKind.STARTUP_CONTEXT,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _init_messages(self):
|
|
417
|
+
self.messages.clear()
|
|
418
|
+
if self.full_system_prompt:
|
|
419
|
+
self.append_and_pipe(
|
|
420
|
+
Message(
|
|
421
|
+
role=Role.SYSTEM,
|
|
422
|
+
content=self.full_system_prompt,
|
|
423
|
+
message_kind=MessageKind.SYSTEM_MESSAGE,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Initial messages are attached right after the system
|
|
428
|
+
# message as user messages, before any tool calls
|
|
429
|
+
for i in self.initial_messages:
|
|
430
|
+
if isinstance(i, Path):
|
|
431
|
+
if msg := self._get_stored_message(i):
|
|
432
|
+
self.append_and_pipe(msg)
|
|
433
|
+
continue
|
|
434
|
+
if isinstance(i, str):
|
|
435
|
+
self.append_and_pipe(
|
|
436
|
+
Message(
|
|
437
|
+
role=Role.USER,
|
|
438
|
+
content=i,
|
|
439
|
+
message_kind=MessageKind.STARTUP_CONTEXT,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
continue
|
|
443
|
+
logger.error(
|
|
444
|
+
"Invalid initial message type=%s",
|
|
445
|
+
type(i).__name__,
|
|
446
|
+
)
|
|
447
|
+
raise ValueError
|
|
448
|
+
|
|
449
|
+
def add(self, *, tools: list[Tool] | None = None, skill: Skill | None = None):
|
|
450
|
+
"""Add tools or a loaded skill and regenerate model-facing instructions."""
|
|
451
|
+
current_tools = list(self.active_tools.values()) + list(
|
|
452
|
+
self.passive_tools.values()
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
self._init_tools(
|
|
456
|
+
tools=current_tools + tools if tools else current_tools, skill=skill
|
|
457
|
+
)
|
|
458
|
+
# Regenerate prompt to include newly added tools (e.g. subtask tools)
|
|
459
|
+
if self.automatic_tool_prompt and self.system_prompt:
|
|
460
|
+
self.full_system_prompt = self._get_full_system_prompt(
|
|
461
|
+
self.automatic_tool_prompt
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _init_skill(self, output: Invoke | Empty | Stop) -> None:
|
|
465
|
+
"""Load a skill's tools only after the skill is invoked.
|
|
466
|
+
|
|
467
|
+
Enforce declared skill dependency order and avoid loading a skill more
|
|
468
|
+
than once during one run.
|
|
469
|
+
"""
|
|
470
|
+
if not isinstance(output, Invoke):
|
|
471
|
+
return
|
|
472
|
+
if (
|
|
473
|
+
skill := (self._skills | self._auto_loaded_skills).get(output.action)
|
|
474
|
+
) is not None:
|
|
475
|
+
if skill in self._loaded_skills:
|
|
476
|
+
return
|
|
477
|
+
if (
|
|
478
|
+
skill.depends_on is not None
|
|
479
|
+
and skill.depends_on not in self._loaded_skills
|
|
480
|
+
):
|
|
481
|
+
raise RuntimeError(
|
|
482
|
+
f"The {skill.name=} depends on {skill.depends_on=}, which has not been loaded."
|
|
483
|
+
)
|
|
484
|
+
self._loaded_skills.add(skill)
|
|
485
|
+
self.add(skill=skill)
|
|
486
|
+
|
|
487
|
+
def auto_load_skills(self):
|
|
488
|
+
"""Load configured automatic skills and emit their instruction messages."""
|
|
489
|
+
if self._auto_loaded_skills:
|
|
490
|
+
self.append_and_pipe(
|
|
491
|
+
get_finalized_message(
|
|
492
|
+
Str(value=AUTO_LOAD_SKILLS_BANNER),
|
|
493
|
+
self.prompt_user_tool,
|
|
494
|
+
message_kind=MessageKind.AUTO_LOAD_BANNER,
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
for skill in self._auto_loaded_skills.values():
|
|
498
|
+
output = Invoke.model_validate(
|
|
499
|
+
{
|
|
500
|
+
"action": skill.name,
|
|
501
|
+
"rationale": auto_load_skill_rationale(skill.name),
|
|
502
|
+
}
|
|
503
|
+
)
|
|
504
|
+
self._init_skill(output)
|
|
505
|
+
tool = self.get_next_tool(self.master_tool, output)
|
|
506
|
+
output = tool(output, self.messages)
|
|
507
|
+
self.append_and_pipe(
|
|
508
|
+
get_finalized_message(
|
|
509
|
+
output,
|
|
510
|
+
tool,
|
|
511
|
+
message_kind=MessageKind.AUTO_LOADED_SKILL,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def _initialize_pipe_for_invoke(self, *, dry_run: bool) -> None:
|
|
516
|
+
"""Initialize run metadata from the current endpoint selection.
|
|
517
|
+
|
|
518
|
+
Resources own their construction caches; a live reference may select a
|
|
519
|
+
different endpoint for each invocation of the same agent.
|
|
520
|
+
"""
|
|
521
|
+
endpoint: LLMEndpoint | MockLLMEndpoint | None = None
|
|
522
|
+
if self.is_agentic:
|
|
523
|
+
configured_endpoint = self.agent_endpoint
|
|
524
|
+
if configured_endpoint is None:
|
|
525
|
+
raise RuntimeError("agentic instance has no endpoint")
|
|
526
|
+
endpoint = resolve_endpoint(configured_endpoint)
|
|
527
|
+
self.pipe.initialize(
|
|
528
|
+
dry_run=dry_run,
|
|
529
|
+
agent_name=self.name,
|
|
530
|
+
agent_description=self.description or None,
|
|
531
|
+
api_name=getattr(endpoint, "api_name", None),
|
|
532
|
+
model_name=getattr(endpoint, "model_name", None),
|
|
533
|
+
max_context_tokens=getattr(endpoint, "max_context_tokens", None),
|
|
534
|
+
temperature=getattr(endpoint, "temperature", None),
|
|
535
|
+
output_format=getattr(endpoint, "output_format", None),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def _advance_by_step(
|
|
539
|
+
self, tool: Tool | None, output: Invoke | Empty
|
|
540
|
+
) -> tuple[Tool | None, Invoke | Empty | Stop | Stop]:
|
|
541
|
+
try:
|
|
542
|
+
self.pipe.raise_if_cancelled()
|
|
543
|
+
tool = self.get_next_tool(tool, output)
|
|
544
|
+
self.pipe.raise_if_cancelled()
|
|
545
|
+
tool_output = self._tool_invocations.invoke(tool, output, self.messages)
|
|
546
|
+
except LLMProviderRequestError as error:
|
|
547
|
+
return self._handle_provider_request_error(error)
|
|
548
|
+
except ExternalCallInterruptedError:
|
|
549
|
+
logger.info(
|
|
550
|
+
"Interrupt caught (ExternalCallInterruptedError) in agent '%s' "
|
|
551
|
+
"while running tool '%s'",
|
|
552
|
+
self.name,
|
|
553
|
+
getattr(tool, "name", None),
|
|
554
|
+
)
|
|
555
|
+
return self._handle_interrupt()
|
|
556
|
+
except KeyboardInterrupt:
|
|
557
|
+
if not self.pipe.interrupted:
|
|
558
|
+
logger.info(
|
|
559
|
+
"KeyboardInterrupt in agent '%s' without interrupt flag set; "
|
|
560
|
+
"re-raising",
|
|
561
|
+
self.name,
|
|
562
|
+
)
|
|
563
|
+
raise
|
|
564
|
+
logger.info(
|
|
565
|
+
"KeyboardInterrupt in agent '%s' with interrupt flag set; "
|
|
566
|
+
"handling as interrupt",
|
|
567
|
+
self.name,
|
|
568
|
+
)
|
|
569
|
+
return self._handle_interrupt()
|
|
570
|
+
message = get_finalized_message(tool_output, tool)
|
|
571
|
+
self.append_and_pipe(message)
|
|
572
|
+
return tool, tool_output
|
|
573
|
+
|
|
574
|
+
def _handle_interrupt(self) -> tuple[None, Empty]:
|
|
575
|
+
logger.info("Handling interrupt for agent '%s'", self.name)
|
|
576
|
+
self.pipe.clear_interrupt()
|
|
577
|
+
self.append_and_pipe(
|
|
578
|
+
get_finalized_message(
|
|
579
|
+
Str(value=INTERRUPTED_GENERATION_CONTEXT),
|
|
580
|
+
message_kind=MessageKind.INTERRUPTED_GENERATION,
|
|
581
|
+
)
|
|
582
|
+
)
|
|
583
|
+
interrupt_input = self.prompt_user_tool.InputModel(
|
|
584
|
+
value=INTERRUPT_PROMPT_TO_USER
|
|
585
|
+
)
|
|
586
|
+
interrupt_output = self.prompt_user_tool(interrupt_input, self.messages)
|
|
587
|
+
message = get_finalized_message(interrupt_output, self.prompt_user_tool)
|
|
588
|
+
self.append_and_pipe(message)
|
|
589
|
+
self._ephemeral_default_tools = []
|
|
590
|
+
return None, Empty()
|
|
591
|
+
|
|
592
|
+
def _handle_provider_request_error(
|
|
593
|
+
self, error: LLMProviderRequestError
|
|
594
|
+
) -> tuple[None, Empty]:
|
|
595
|
+
logger.info(
|
|
596
|
+
"Recoverable provider request failure in agent '%s': %s",
|
|
597
|
+
self.name,
|
|
598
|
+
type(error).__name__,
|
|
599
|
+
)
|
|
600
|
+
retry_input = self.prompt_user_tool.InputModel(
|
|
601
|
+
value=LLM_PROVIDER_REQUEST_RETRY_PROMPT
|
|
602
|
+
)
|
|
603
|
+
retry_output = self.prompt_user_tool(retry_input, self.messages)
|
|
604
|
+
self.append_and_pipe(get_finalized_message(retry_output, self.prompt_user_tool))
|
|
605
|
+
self._ephemeral_default_tools = []
|
|
606
|
+
return None, Empty()
|
|
607
|
+
|
|
608
|
+
def invoke(
|
|
609
|
+
self,
|
|
610
|
+
*,
|
|
611
|
+
input: Empty | None = None,
|
|
612
|
+
dry_run: bool = False,
|
|
613
|
+
) -> tuple[Stop, list[Message]]:
|
|
614
|
+
"""Run the agent synchronously until it stops or is cancelled."""
|
|
615
|
+
stack_token = None
|
|
616
|
+
output_token = None
|
|
617
|
+
exit_status = RunStatus.FAILED
|
|
618
|
+
logger.debug(
|
|
619
|
+
"Invoke start: agent='%s' stack=%s dry_run=%s",
|
|
620
|
+
self.name,
|
|
621
|
+
get_active_agent_stack(),
|
|
622
|
+
dry_run,
|
|
623
|
+
)
|
|
624
|
+
try:
|
|
625
|
+
if self.interaction_mode is not None:
|
|
626
|
+
output_token = bind_output(self.interaction_mode)
|
|
627
|
+
self._initialize_pipe_for_invoke(dry_run=dry_run)
|
|
628
|
+
if not dry_run:
|
|
629
|
+
stack_token = push_active_agent(self.name)
|
|
630
|
+
self._init_messages()
|
|
631
|
+
self._loaded_skills.clear()
|
|
632
|
+
self.auto_load_skills()
|
|
633
|
+
self._ephemeral_default_tools = []
|
|
634
|
+
|
|
635
|
+
tool = None
|
|
636
|
+
output = Empty() if input is None else input
|
|
637
|
+
while True:
|
|
638
|
+
tool, output = self._advance_by_step(tool, output)
|
|
639
|
+
if isinstance(output, Stop):
|
|
640
|
+
exit_status = RunStatus.COMPLETED
|
|
641
|
+
return output, self.messages
|
|
642
|
+
except (StopAgent, KeyboardInterrupt, ExternalCallCancelledError) as e:
|
|
643
|
+
exit_status = RunStatus.CANCELLED
|
|
644
|
+
logger.info(
|
|
645
|
+
"Invoke terminating: agent='%s' via %s",
|
|
646
|
+
self.name,
|
|
647
|
+
type(e).__name__,
|
|
648
|
+
)
|
|
649
|
+
if isinstance(e, KeyboardInterrupt):
|
|
650
|
+
raise
|
|
651
|
+
return Stop(), self.messages
|
|
652
|
+
finally:
|
|
653
|
+
logger.debug(
|
|
654
|
+
"Invoke end: agent='%s' status=%s",
|
|
655
|
+
self.name,
|
|
656
|
+
exit_status,
|
|
657
|
+
)
|
|
658
|
+
self.pipe.finalize_run(status=exit_status)
|
|
659
|
+
if stack_token is not None:
|
|
660
|
+
pop_active_agent(stack_token)
|
|
661
|
+
if output_token is not None:
|
|
662
|
+
reset_output(output_token)
|
|
663
|
+
|
|
664
|
+
def show_agent_info(self):
|
|
665
|
+
"""Render configured tools, storage locations, and initial messages."""
|
|
666
|
+
self.pipe.initialize(dry_run=True)
|
|
667
|
+
|
|
668
|
+
console = Console()
|
|
669
|
+
self._init_messages()
|
|
670
|
+
self.auto_load_skills()
|
|
671
|
+
self._show_tools(console)
|
|
672
|
+
print()
|
|
673
|
+
self._show_locations(console)
|
|
674
|
+
print()
|
|
675
|
+
self._show_initial_messages(console)
|
|
676
|
+
|
|
677
|
+
def _show_tools(self, console: Console):
|
|
678
|
+
conf_table = Table(title="Tools", show_header=True, header_style="bold magenta")
|
|
679
|
+
conf_table.add_column("Type", style="dim")
|
|
680
|
+
conf_table.add_column("Name", style="cyan")
|
|
681
|
+
conf_table.add_column("Description")
|
|
682
|
+
conf_table.add_column("Chained To")
|
|
683
|
+
|
|
684
|
+
no_description = "Not provided"
|
|
685
|
+
|
|
686
|
+
def _get_chained_to(t: Tool) -> str:
|
|
687
|
+
if not t.chained_to:
|
|
688
|
+
return "-"
|
|
689
|
+
return ", ".join([c.name for c in t.chained_to])
|
|
690
|
+
|
|
691
|
+
conf_table.add_row(
|
|
692
|
+
"Main Prompter",
|
|
693
|
+
self.master_tool.name if self.master_tool.name else "Not set",
|
|
694
|
+
"Set the flag 'is_agentic' to true for agentic flows.",
|
|
695
|
+
"-",
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
for id, t in self.active_tools.items():
|
|
699
|
+
if id != self.master_tool.id:
|
|
700
|
+
conf_table.add_row(
|
|
701
|
+
"Active",
|
|
702
|
+
t.name,
|
|
703
|
+
t.description or no_description,
|
|
704
|
+
_get_chained_to(t),
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
for id, t in self.passive_tools.items():
|
|
708
|
+
conf_table.add_row(
|
|
709
|
+
"Passive",
|
|
710
|
+
t.name,
|
|
711
|
+
t.description or no_description,
|
|
712
|
+
_get_chained_to(t),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
default_tool_names = (
|
|
716
|
+
", ".join(t.name for t in self.default_tools)
|
|
717
|
+
if self.default_tools
|
|
718
|
+
else "None"
|
|
719
|
+
)
|
|
720
|
+
conf_table.add_row(
|
|
721
|
+
"Default Tools",
|
|
722
|
+
default_tool_names,
|
|
723
|
+
"Configured fallback tool list.",
|
|
724
|
+
"-",
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
console.print(conf_table, end="\n\n")
|
|
728
|
+
|
|
729
|
+
def _show_locations(self, console: Console):
|
|
730
|
+
loc_table = Table(
|
|
731
|
+
title="Persisted Locations", show_header=True, header_style="bold green"
|
|
732
|
+
)
|
|
733
|
+
loc_table.add_column("Item", style="dim")
|
|
734
|
+
loc_table.add_column("Path", style="blue")
|
|
735
|
+
loc_table.add_column("Items", justify="right", style="dim")
|
|
736
|
+
|
|
737
|
+
data_root = self.pipe.data_path if self.pipe.data_path else None
|
|
738
|
+
if data_root is None:
|
|
739
|
+
loc_table.add_row("Data location", "Not set", "-")
|
|
740
|
+
console.print(loc_table, end="\n\n")
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
n = (
|
|
744
|
+
sum(1 for _ in data_root.iterdir())
|
|
745
|
+
if data_root.is_dir() and data_root.exists()
|
|
746
|
+
else 0
|
|
747
|
+
)
|
|
748
|
+
loc_table.add_row("Data location", str(data_root), str(n))
|
|
749
|
+
console.print(loc_table, end="\n\n")
|
|
750
|
+
|
|
751
|
+
def _show_initial_messages(self, console: Console):
|
|
752
|
+
msg_table = Table(
|
|
753
|
+
title="Initial Messages", show_header=True, header_style="bold yellow"
|
|
754
|
+
)
|
|
755
|
+
msg_table.add_column("Type", style="dim")
|
|
756
|
+
msg_table.add_column("Content")
|
|
757
|
+
if Role.SYSTEM not in {m.role for m in self.messages}:
|
|
758
|
+
self.messages = [Message(role=Role.SYSTEM, content="None")] + self.messages
|
|
759
|
+
|
|
760
|
+
for m in self.messages:
|
|
761
|
+
if m.role == Role.USER:
|
|
762
|
+
msg_table.add_row("User", m.content)
|
|
763
|
+
msg_table.add_row("", "")
|
|
764
|
+
continue
|
|
765
|
+
msg_table.add_row("System", m.content)
|
|
766
|
+
msg_table.add_row("", "")
|
|
767
|
+
|
|
768
|
+
console.print(msg_table)
|
|
769
|
+
self.messages.clear()
|