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.
Files changed (63) hide show
  1. roboz/__init__.py +91 -0
  2. roboz/_naming.py +19 -0
  3. roboz/agent/__init__.py +21 -0
  4. roboz/agent/_execution_context.py +53 -0
  5. roboz/agent/_identifiers.py +15 -0
  6. roboz/agent/_notifications.py +25 -0
  7. roboz/agent/_prompts.py +154 -0
  8. roboz/agent/_tool_observer.py +159 -0
  9. roboz/agent/background_agent.py +157 -0
  10. roboz/agent/core.py +769 -0
  11. roboz/agent/prompt_agent_tool.py +37 -0
  12. roboz/agent/subagent.py +28 -0
  13. roboz/dependencies.py +297 -0
  14. roboz/deployment.py +151 -0
  15. roboz/exceptions.py +115 -0
  16. roboz/llm/__init__.py +56 -0
  17. roboz/llm/_diagnostics.py +521 -0
  18. roboz/llm/_retry.py +118 -0
  19. roboz/llm/_truncation.py +125 -0
  20. roboz/llm/binding.py +150 -0
  21. roboz/llm/calls.py +760 -0
  22. roboz/llm/completion.py +246 -0
  23. roboz/llm/endpoints.py +296 -0
  24. roboz/llm/openrouter.py +107 -0
  25. roboz/llm/prompts.py +79 -0
  26. roboz/models/__init__.py +61 -0
  27. roboz/models/_schema.py +82 -0
  28. roboz/models/_serialization.py +60 -0
  29. roboz/models/_telemetry.py +26 -0
  30. roboz/models/core.py +155 -0
  31. roboz/models/truncation.py +68 -0
  32. roboz/py.typed +1 -0
  33. roboz/runtime/__init__.py +82 -0
  34. roboz/runtime/_environment.py +19 -0
  35. roboz/runtime/_external.py +340 -0
  36. roboz/runtime/_logging.py +87 -0
  37. roboz/runtime/_paths.py +60 -0
  38. roboz/runtime/events.py +84 -0
  39. roboz/runtime/io.py +108 -0
  40. roboz/runtime/observability.py +108 -0
  41. roboz/runtime/persistence/__init__.py +39 -0
  42. roboz/runtime/persistence/activity.py +106 -0
  43. roboz/runtime/persistence/schema.py +155 -0
  44. roboz/runtime/pipe.py +384 -0
  45. roboz/runtime/sinks.py +277 -0
  46. roboz/skill/__init__.py +5 -0
  47. roboz/skill/_prompts.py +50 -0
  48. roboz/skill/core.py +140 -0
  49. roboz/tooling/__init__.py +6 -0
  50. roboz/tooling/_prompts.py +124 -0
  51. roboz/tooling/_protocols.py +61 -0
  52. roboz/tooling/_typing.py +20 -0
  53. roboz/tooling/context.py +101 -0
  54. roboz/tooling/core.py +289 -0
  55. roboz/tooling/decorators.py +236 -0
  56. roboz/tools/__init__.py +20 -0
  57. roboz/tools/_identifiers.py +8 -0
  58. roboz/tools/control.py +54 -0
  59. roboz/tools/interaction.py +68 -0
  60. roboz-0.1.2.dev2.dist-info/METADATA +215 -0
  61. roboz-0.1.2.dev2.dist-info/RECORD +63 -0
  62. roboz-0.1.2.dev2.dist-info/WHEEL +4 -0
  63. 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()