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/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Golden-path authoring API for Roboz."""
|
|
2
|
+
|
|
3
|
+
from roboz.agent import (
|
|
4
|
+
Agent,
|
|
5
|
+
BackgroundAgentStatus,
|
|
6
|
+
prompt_agent,
|
|
7
|
+
run_background_agent,
|
|
8
|
+
run_subagent,
|
|
9
|
+
)
|
|
10
|
+
from roboz.dependencies import (
|
|
11
|
+
DependencyRoute,
|
|
12
|
+
ExecutableDependency,
|
|
13
|
+
ExternalDependency,
|
|
14
|
+
ExternalDependencyKind,
|
|
15
|
+
ExternalDependencyReference,
|
|
16
|
+
ExternalDependencySource,
|
|
17
|
+
LazyExternalDependency,
|
|
18
|
+
ModelEndpointDependency,
|
|
19
|
+
NetworkServiceDependency,
|
|
20
|
+
)
|
|
21
|
+
from roboz.models import (
|
|
22
|
+
AgentBaseModel,
|
|
23
|
+
All,
|
|
24
|
+
Empty,
|
|
25
|
+
HashMaps,
|
|
26
|
+
Int,
|
|
27
|
+
Invoke,
|
|
28
|
+
Location,
|
|
29
|
+
LocationStr,
|
|
30
|
+
Message,
|
|
31
|
+
Role,
|
|
32
|
+
Stop,
|
|
33
|
+
StopLocation,
|
|
34
|
+
Str,
|
|
35
|
+
Strs,
|
|
36
|
+
)
|
|
37
|
+
from roboz.skill import Skill
|
|
38
|
+
from roboz.tooling import Factory, Tool
|
|
39
|
+
from roboz.tooling.context import Ctx
|
|
40
|
+
from roboz.tooling.decorators import factory, tool
|
|
41
|
+
from roboz.tools import (
|
|
42
|
+
PromptUser,
|
|
43
|
+
message_user,
|
|
44
|
+
prompt_user,
|
|
45
|
+
prompt_user_at_start,
|
|
46
|
+
stop,
|
|
47
|
+
stop_after,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Ctx",
|
|
52
|
+
"Agent",
|
|
53
|
+
"AgentBaseModel",
|
|
54
|
+
"All",
|
|
55
|
+
"BackgroundAgentStatus",
|
|
56
|
+
"Empty",
|
|
57
|
+
"DependencyRoute",
|
|
58
|
+
"ExecutableDependency",
|
|
59
|
+
"ExternalDependency",
|
|
60
|
+
"ExternalDependencyKind",
|
|
61
|
+
"ExternalDependencyReference",
|
|
62
|
+
"ExternalDependencySource",
|
|
63
|
+
"Factory",
|
|
64
|
+
"HashMaps",
|
|
65
|
+
"Int",
|
|
66
|
+
"Invoke",
|
|
67
|
+
"LazyExternalDependency",
|
|
68
|
+
"Location",
|
|
69
|
+
"LocationStr",
|
|
70
|
+
"Message",
|
|
71
|
+
"ModelEndpointDependency",
|
|
72
|
+
"NetworkServiceDependency",
|
|
73
|
+
"PromptUser",
|
|
74
|
+
"Role",
|
|
75
|
+
"Skill",
|
|
76
|
+
"Stop",
|
|
77
|
+
"StopLocation",
|
|
78
|
+
"Str",
|
|
79
|
+
"Strs",
|
|
80
|
+
"Tool",
|
|
81
|
+
"factory",
|
|
82
|
+
"message_user",
|
|
83
|
+
"prompt_agent",
|
|
84
|
+
"prompt_user",
|
|
85
|
+
"prompt_user_at_start",
|
|
86
|
+
"run_background_agent",
|
|
87
|
+
"run_subagent",
|
|
88
|
+
"stop",
|
|
89
|
+
"stop_after",
|
|
90
|
+
"tool",
|
|
91
|
+
]
|
roboz/_naming.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Validation for identifiers exposed to language models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_PUBLIC_NAME = re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*", flags=re.ASCII)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_public_name(name: str, *, kind: str) -> str:
|
|
11
|
+
"""Return ``name`` when it is a lowercase ASCII snake-case identifier."""
|
|
12
|
+
if not isinstance(name, str):
|
|
13
|
+
raise TypeError(f"{kind} name must be a string")
|
|
14
|
+
if _PUBLIC_NAME.fullmatch(name) is None:
|
|
15
|
+
raise ValueError(
|
|
16
|
+
f"{kind} name must be lowercase ASCII snake_case and match "
|
|
17
|
+
"'[a-z][a-z0-9]*(?:_[a-z0-9]+)*'"
|
|
18
|
+
)
|
|
19
|
+
return name
|
roboz/agent/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Agent construction, execution, and delegation interfaces."""
|
|
2
|
+
|
|
3
|
+
from roboz.agent._execution_context import get_active_agent_stack
|
|
4
|
+
from roboz.agent.background_agent import (
|
|
5
|
+
BackgroundAgentPhase,
|
|
6
|
+
BackgroundAgentStatus,
|
|
7
|
+
run_background_agent,
|
|
8
|
+
)
|
|
9
|
+
from roboz.agent.core import Agent
|
|
10
|
+
from roboz.agent.prompt_agent_tool import prompt_agent
|
|
11
|
+
from roboz.agent.subagent import run_subagent
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Agent",
|
|
15
|
+
"BackgroundAgentPhase",
|
|
16
|
+
"BackgroundAgentStatus",
|
|
17
|
+
"get_active_agent_stack",
|
|
18
|
+
"prompt_agent",
|
|
19
|
+
"run_background_agent",
|
|
20
|
+
"run_subagent",
|
|
21
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Context-local tracking of nested agent execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyword
|
|
6
|
+
from contextvars import ContextVar, Token
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_active_agent_stack: ContextVar[tuple[str, ...]] = ContextVar(
|
|
10
|
+
"_active_agent_stack", default=()
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_active_agent_stack() -> tuple[str, ...]:
|
|
15
|
+
return _active_agent_stack.get()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def push_active_agent(agent_name: str) -> Token[tuple[str, ...]]:
|
|
19
|
+
stack = _active_agent_stack.get()
|
|
20
|
+
return _active_agent_stack.set(stack + (agent_name,))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pop_active_agent(token: Token[tuple[str, ...]]) -> None:
|
|
24
|
+
_active_agent_stack.reset(token)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_WINDOWS_RESERVED_NAMES: frozenset[str] = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"con",
|
|
30
|
+
"prn",
|
|
31
|
+
"aux",
|
|
32
|
+
"nul",
|
|
33
|
+
*(f"com{i}" for i in range(1, 10)),
|
|
34
|
+
*(f"lpt{i}" for i in range(1, 10)),
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_agent_name(name: str) -> str:
|
|
40
|
+
if not isinstance(name, str):
|
|
41
|
+
raise TypeError(f"agent name must be str, got {type(name).__name__}")
|
|
42
|
+
stripped = name.strip()
|
|
43
|
+
if not stripped:
|
|
44
|
+
raise ValueError("agent name cannot be blank")
|
|
45
|
+
if not stripped.isidentifier():
|
|
46
|
+
raise ValueError(f"agent name must be a valid Python identifier, got {name!r}")
|
|
47
|
+
if keyword.iskeyword(stripped):
|
|
48
|
+
raise ValueError(f"agent name cannot be a Python keyword, got {name!r}")
|
|
49
|
+
if stripped.lower() in _WINDOWS_RESERVED_NAMES:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"agent name is reserved as a device name on Windows, got {name!r}"
|
|
52
|
+
)
|
|
53
|
+
return stripped
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Stable identifiers for the built-in agent tools.
|
|
2
|
+
|
|
3
|
+
Single source of truth for the built-in tool names that downstream packages
|
|
4
|
+
compare against (e.g. ``caller == PROMPT_USER_TOOL_NAME``). Change values here when
|
|
5
|
+
renaming the corresponding tool; never duplicate these strings as literals elsewhere.
|
|
6
|
+
Each value must match the actual ``tool.name`` (guarded by a test).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
# --- Tools (``tool.name``) — imperative expressions `do` ---
|
|
12
|
+
|
|
13
|
+
PROMPT_AGENT_TOOL_NAME: Final[str] = "prompt_agent"
|
|
14
|
+
RUN_BACKGROUND_AGENT_TOOL_NAME: Final[str] = "run_background_agent"
|
|
15
|
+
RUN_SUBAGENT_TOOL_NAME: Final[str] = "run_subagent"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Notification text produced while agents load skills and tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
AUTO_LOAD_SKILLS_BANNER: Final[str] = (
|
|
8
|
+
"Here are skills that *I* have chosen to invoke automatically "
|
|
9
|
+
"and the respective instructions on how to use "
|
|
10
|
+
"them. They are from this point onwards freely at your disposal."
|
|
11
|
+
)
|
|
12
|
+
INTERRUPT_PROMPT_TO_USER: Final[str] = "Action interrupted!"
|
|
13
|
+
INTERRUPTED_GENERATION_CONTEXT: Final[str] = (
|
|
14
|
+
"The previous assistant generation was interrupted by the user before it "
|
|
15
|
+
"completed. Treat the user's next message as a change of direction."
|
|
16
|
+
)
|
|
17
|
+
LLM_PROVIDER_REQUEST_RETRY_PROMPT: Final[str] = (
|
|
18
|
+
"The selected model could not complete the request. Choose another model, "
|
|
19
|
+
"then enter retry and Send."
|
|
20
|
+
)
|
|
21
|
+
SUBAGENT_NO_OUTCOME_PLACEHOLDER: Final[str] = "The agent finished without a message."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def auto_load_skill_rationale(skill_name: str) -> str:
|
|
25
|
+
return f"Loading the {skill_name} skill"
|
roboz/agent/_prompts.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""System-prompt assembly for agentic and non-agentic runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Sequence
|
|
8
|
+
|
|
9
|
+
from roboz.skill._prompts import AGENT_SKILL_USE_INSTRUCTIONS
|
|
10
|
+
from roboz.tooling._prompts import AGENT_TOOL_USE_INSTRUCTIONS, _get_single_instruction
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from roboz.skill.core import Skill
|
|
14
|
+
from roboz.tooling.core import Tool
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_available_tools_and_skills_section(
|
|
20
|
+
tools: Sequence[Tool] | None,
|
|
21
|
+
skills: Sequence[Skill] | None,
|
|
22
|
+
) -> str:
|
|
23
|
+
tools = tools if tools is not None else []
|
|
24
|
+
skills = skills if skills is not None else []
|
|
25
|
+
skill_names = {s.name for s in skills if s is not None}
|
|
26
|
+
section = "## Available tools and skills\n\n"
|
|
27
|
+
for t in tools:
|
|
28
|
+
if t is None or t.chained_to or t.name in skill_names:
|
|
29
|
+
continue
|
|
30
|
+
section += f"{_get_single_instruction(t)}\n\n"
|
|
31
|
+
for s in skills:
|
|
32
|
+
if s is None:
|
|
33
|
+
continue
|
|
34
|
+
section += f"{_get_single_instruction(s.get_skill_as_tool())}\n\n"
|
|
35
|
+
return section.rstrip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_example_agent_discussion() -> str:
|
|
39
|
+
"""Return a multi-turn example showing how turns appear in the agent (roles and JSON shapes)."""
|
|
40
|
+
section = "## Example discussion\n\n<example>\n\n"
|
|
41
|
+
section += (
|
|
42
|
+
"The following is a **full multi-turn discussion**: many separate "
|
|
43
|
+
"Assistant/User rounds over time—not one assistant message. Each "
|
|
44
|
+
"`Assistant:` line is exactly **one** assistant reply containing **one** "
|
|
45
|
+
"JSON object; never paste several JSON objects into a single assistant "
|
|
46
|
+
"reply.\n\n"
|
|
47
|
+
)
|
|
48
|
+
section += (
|
|
49
|
+
"System: This is an example system message for illustrative purposes.\n\n"
|
|
50
|
+
)
|
|
51
|
+
section += "Assistant: "
|
|
52
|
+
section += (
|
|
53
|
+
json.dumps(
|
|
54
|
+
{
|
|
55
|
+
"action": "prompt_user",
|
|
56
|
+
"rationale": "Getting the user's request.",
|
|
57
|
+
"value": "Hi there! How should we start the example?",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
+ "\n\n"
|
|
61
|
+
)
|
|
62
|
+
section += "User: "
|
|
63
|
+
section += (
|
|
64
|
+
json.dumps(
|
|
65
|
+
{
|
|
66
|
+
"caller": "prompt_user",
|
|
67
|
+
"value": "Please demonstrate the example tool by calling it with value 42.",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
+ "\n\n"
|
|
71
|
+
)
|
|
72
|
+
section += "Assistant: "
|
|
73
|
+
section += (
|
|
74
|
+
json.dumps(
|
|
75
|
+
{
|
|
76
|
+
"action": "example_tool",
|
|
77
|
+
"rationale": "Calling the example_tool to demonstrate its structure as requested.",
|
|
78
|
+
"value": 42,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
+ "\n\n"
|
|
82
|
+
)
|
|
83
|
+
section += "User: "
|
|
84
|
+
section += (
|
|
85
|
+
json.dumps(
|
|
86
|
+
{
|
|
87
|
+
"caller": "example_tool",
|
|
88
|
+
"value": "Example: 42",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
+ "\n\n"
|
|
92
|
+
)
|
|
93
|
+
section += "Assistant: "
|
|
94
|
+
section += (
|
|
95
|
+
json.dumps(
|
|
96
|
+
{
|
|
97
|
+
"action": "prompt_user",
|
|
98
|
+
"rationale": "Getting the user's next request.",
|
|
99
|
+
"value": "Anything else I can help you with?",
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
+ "\n\n"
|
|
103
|
+
)
|
|
104
|
+
section += "User: "
|
|
105
|
+
section += (
|
|
106
|
+
json.dumps(
|
|
107
|
+
{
|
|
108
|
+
"caller": "prompt_user",
|
|
109
|
+
"value": "Please load the example skill.",
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
+ "\n\n"
|
|
113
|
+
)
|
|
114
|
+
section += "Assistant: "
|
|
115
|
+
section += (
|
|
116
|
+
json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"action": "example_skill",
|
|
119
|
+
"rationale": "Loading the example_skill as requested.",
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
+ "\n\n"
|
|
123
|
+
)
|
|
124
|
+
section += "User: "
|
|
125
|
+
section += json.dumps(
|
|
126
|
+
{
|
|
127
|
+
"caller": "example_skill",
|
|
128
|
+
"value": "# Instructions\n\nYou are now using the Example Skill... "
|
|
129
|
+
"<redacted the rest as this is an example>",
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
section += "\n\n</example>"
|
|
133
|
+
return section
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_agentic_system_prompt(
|
|
137
|
+
*,
|
|
138
|
+
system_prompt: str = "",
|
|
139
|
+
tools: Sequence[Tool] | None = None,
|
|
140
|
+
skills: Sequence[Skill] | None = None,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Build the complete technical prompt for an agentic run.
|
|
143
|
+
|
|
144
|
+
Keep detailed tool and skill calling instructions out of the supplied
|
|
145
|
+
system prompt because this builder generates them consistently.
|
|
146
|
+
"""
|
|
147
|
+
parts = [
|
|
148
|
+
system_prompt.strip(),
|
|
149
|
+
AGENT_TOOL_USE_INSTRUCTIONS,
|
|
150
|
+
AGENT_SKILL_USE_INSTRUCTIONS,
|
|
151
|
+
_get_available_tools_and_skills_section(tools, skills),
|
|
152
|
+
get_example_agent_discussion(),
|
|
153
|
+
]
|
|
154
|
+
return "\n\n".join(p for p in parts if p)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Lifecycle observation around individual tool calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from logging import getLogger
|
|
7
|
+
from typing import Any, Final
|
|
8
|
+
|
|
9
|
+
from roboz.exceptions import (
|
|
10
|
+
ExternalCallCancelledError,
|
|
11
|
+
ExternalCallInterruptedError,
|
|
12
|
+
LLMProviderRequestError,
|
|
13
|
+
)
|
|
14
|
+
from roboz.models import Empty, Invoke, Message, Stop
|
|
15
|
+
from roboz.runtime._logging import LogScalar, log_with_data
|
|
16
|
+
from roboz.runtime.observability import (
|
|
17
|
+
LifecycleKind,
|
|
18
|
+
ObservedFailure,
|
|
19
|
+
RuntimeEventCategory,
|
|
20
|
+
RuntimeEventKind,
|
|
21
|
+
RuntimeEventLevel,
|
|
22
|
+
)
|
|
23
|
+
from roboz.runtime.pipe import EventPipe
|
|
24
|
+
from roboz.tooling.core import Tool
|
|
25
|
+
|
|
26
|
+
logger = getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_AGENT_KEY: Final[str] = "agent"
|
|
29
|
+
_TOOL_KEY: Final[str] = "tool"
|
|
30
|
+
_DURATION_MS_KEY: Final[str] = "duration_ms"
|
|
31
|
+
_OUTPUT_TYPE_KEY: Final[str] = "output_type"
|
|
32
|
+
_OUTPUT_CHARS_KEY: Final[str] = "output_chars"
|
|
33
|
+
_ERROR_TYPE_KEY: Final[str] = "error_type"
|
|
34
|
+
_MILLISECONDS_PER_SECOND: Final[int] = 1_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolInvocationObserver:
|
|
38
|
+
"""Invoke tools while emitting their agent-scoped runtime lifecycle.
|
|
39
|
+
|
|
40
|
+
This wrapper is observability-only. It does not recover from, translate, or
|
|
41
|
+
otherwise handle failures; after recording one, it re-raises the original
|
|
42
|
+
exception unchanged so the agent retains ownership of control-flow policy.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, agent_name: str, pipe: EventPipe) -> None:
|
|
46
|
+
self._agent_name = agent_name
|
|
47
|
+
self._pipe = pipe
|
|
48
|
+
|
|
49
|
+
def invoke(
|
|
50
|
+
self,
|
|
51
|
+
tool: Tool[Any, Any],
|
|
52
|
+
input: Invoke | Empty,
|
|
53
|
+
messages: list[Message],
|
|
54
|
+
) -> Invoke | Empty | Stop:
|
|
55
|
+
started = time.monotonic()
|
|
56
|
+
self._emit(
|
|
57
|
+
tool=tool,
|
|
58
|
+
kind=LifecycleKind.STARTED,
|
|
59
|
+
level=RuntimeEventLevel.INFO,
|
|
60
|
+
message="Tool call started",
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
output = tool(input, messages)
|
|
64
|
+
except (Exception, KeyboardInterrupt) as error:
|
|
65
|
+
self._emit_failure(tool=tool, error=error, started=started)
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
output_chars = len(output.model_dump_json())
|
|
69
|
+
self._emit(
|
|
70
|
+
tool=tool,
|
|
71
|
+
kind=LifecycleKind.SUCCEEDED,
|
|
72
|
+
level=RuntimeEventLevel.INFO,
|
|
73
|
+
message="Tool call succeeded",
|
|
74
|
+
data={
|
|
75
|
+
_DURATION_MS_KEY: round(
|
|
76
|
+
(time.monotonic() - started) * _MILLISECONDS_PER_SECOND
|
|
77
|
+
),
|
|
78
|
+
_OUTPUT_TYPE_KEY: type(output).__name__,
|
|
79
|
+
_OUTPUT_CHARS_KEY: output_chars,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
return output
|
|
83
|
+
|
|
84
|
+
def _emit_failure(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
tool: Tool[Any, Any],
|
|
88
|
+
error: BaseException,
|
|
89
|
+
started: float,
|
|
90
|
+
) -> None:
|
|
91
|
+
failure = _failure_for(error)
|
|
92
|
+
self._emit(
|
|
93
|
+
tool=tool,
|
|
94
|
+
kind=failure.kind,
|
|
95
|
+
level=failure.level,
|
|
96
|
+
message=f"Tool call {failure.kind}",
|
|
97
|
+
data={
|
|
98
|
+
_DURATION_MS_KEY: round(
|
|
99
|
+
(time.monotonic() - started) * _MILLISECONDS_PER_SECOND
|
|
100
|
+
),
|
|
101
|
+
_ERROR_TYPE_KEY: type(error).__name__,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _emit(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
tool: Tool[Any, Any],
|
|
109
|
+
kind: RuntimeEventKind,
|
|
110
|
+
level: RuntimeEventLevel,
|
|
111
|
+
message: str,
|
|
112
|
+
data: dict[str, object] | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
observation = {_TOOL_KEY: tool.name, **(data or {})}
|
|
115
|
+
log_data: dict[str, LogScalar] = {_AGENT_KEY: self._agent_name}
|
|
116
|
+
log_data.update(
|
|
117
|
+
(key, value)
|
|
118
|
+
for key, value in observation.items()
|
|
119
|
+
if value is None or isinstance(value, (str, int, float, bool))
|
|
120
|
+
)
|
|
121
|
+
details: list[str] = []
|
|
122
|
+
duration_ms = observation.get(_DURATION_MS_KEY)
|
|
123
|
+
if isinstance(duration_ms, int):
|
|
124
|
+
details.append(f"duration_ms={duration_ms}")
|
|
125
|
+
output_type = observation.get(_OUTPUT_TYPE_KEY)
|
|
126
|
+
if isinstance(output_type, str):
|
|
127
|
+
details.append(f"result={output_type}")
|
|
128
|
+
error_type = observation.get(_ERROR_TYPE_KEY)
|
|
129
|
+
if isinstance(error_type, str):
|
|
130
|
+
details.append(f"error_type={error_type}")
|
|
131
|
+
suffix = f", {', '.join(details)}" if details else ""
|
|
132
|
+
log_with_data(
|
|
133
|
+
logger,
|
|
134
|
+
level.logging_level,
|
|
135
|
+
f"Tool call {kind}: {tool.name} (agent={self._agent_name}{suffix})",
|
|
136
|
+
log_data,
|
|
137
|
+
)
|
|
138
|
+
self._pipe.emit_runtime_event(
|
|
139
|
+
category=RuntimeEventCategory.TOOL,
|
|
140
|
+
kind=kind,
|
|
141
|
+
level=level,
|
|
142
|
+
message=f"{message}: {tool.name}",
|
|
143
|
+
data=observation,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _failure_for(error: BaseException) -> ObservedFailure:
|
|
148
|
+
match error:
|
|
149
|
+
case LLMProviderRequestError():
|
|
150
|
+
return ObservedFailure.failed(level=RuntimeEventLevel.WARNING)
|
|
151
|
+
case ExternalCallInterruptedError() | KeyboardInterrupt():
|
|
152
|
+
return ObservedFailure.interrupted(level=RuntimeEventLevel.WARNING)
|
|
153
|
+
case ExternalCallCancelledError():
|
|
154
|
+
return ObservedFailure.cancelled(level=RuntimeEventLevel.WARNING)
|
|
155
|
+
case _:
|
|
156
|
+
return ObservedFailure.failed()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["ToolInvocationObserver"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Run agents in background threads."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import partial
|
|
8
|
+
from threading import Event
|
|
9
|
+
from typing import Final, Literal
|
|
10
|
+
|
|
11
|
+
from roboz.models import Empty, Message
|
|
12
|
+
from roboz.models.truncation import NO_MESSAGE
|
|
13
|
+
from roboz.runtime.events import PipeEvent, RunLifecycleEvent
|
|
14
|
+
from roboz.tooling.context import Ctx, _prepare_context
|
|
15
|
+
from roboz.tooling.decorators import factory
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
type BackgroundAgentPhase = Literal["started", "alive", "restarted"]
|
|
20
|
+
|
|
21
|
+
BACKGROUND_START_TIMEOUT_S: Final[float] = 5.0
|
|
22
|
+
_STARTED_PHASE: Final[BackgroundAgentPhase] = "started"
|
|
23
|
+
_ALIVE_PHASE: Final[BackgroundAgentPhase] = "alive"
|
|
24
|
+
_RESTARTED_PHASE: Final[BackgroundAgentPhase] = "restarted"
|
|
25
|
+
_RUN_STARTED_KIND: Final[Literal["started"]] = "started"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class BackgroundAgentState:
|
|
30
|
+
thread: threading.Thread | None = None
|
|
31
|
+
checks: int = 0
|
|
32
|
+
started_monotonic: float | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackgroundAgentStatus(Empty):
|
|
36
|
+
"""Evolving heartbeat for a background daemon's trigger tool."""
|
|
37
|
+
|
|
38
|
+
status: BackgroundAgentPhase
|
|
39
|
+
agent: str
|
|
40
|
+
checks: int
|
|
41
|
+
uptime: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fmt_uptime(seconds: float) -> str:
|
|
45
|
+
total = int(seconds)
|
|
46
|
+
if total < 60:
|
|
47
|
+
return f"{total}s"
|
|
48
|
+
minutes, secs = divmod(total, 60)
|
|
49
|
+
if minutes < 60:
|
|
50
|
+
return f"{minutes}m{secs:02d}s"
|
|
51
|
+
hours, minutes = divmod(minutes, 60)
|
|
52
|
+
return f"{hours}h{minutes:02d}m"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _invoke_agent(ctx: Ctx) -> None:
|
|
56
|
+
agent = ctx.agent
|
|
57
|
+
logger.debug(
|
|
58
|
+
"Background agent invoke starting (name=%s, thread=%s)",
|
|
59
|
+
agent.name,
|
|
60
|
+
threading.current_thread().name,
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
agent.invoke()
|
|
64
|
+
except Exception as error: # noqa: BLE001 - background agents must not crash callers
|
|
65
|
+
logger.error(
|
|
66
|
+
"Background agent failed (name=%s, error_type=%s)",
|
|
67
|
+
agent.name,
|
|
68
|
+
type(error).__name__,
|
|
69
|
+
)
|
|
70
|
+
finally:
|
|
71
|
+
logger.debug("Background agent invoke returned (name=%s)", agent.name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _status(
|
|
75
|
+
ctx: Ctx,
|
|
76
|
+
*,
|
|
77
|
+
status: BackgroundAgentPhase,
|
|
78
|
+
checks: int,
|
|
79
|
+
started_monotonic: float,
|
|
80
|
+
) -> BackgroundAgentStatus:
|
|
81
|
+
return BackgroundAgentStatus(
|
|
82
|
+
status=status,
|
|
83
|
+
agent=ctx.agent.name,
|
|
84
|
+
checks=checks,
|
|
85
|
+
uptime=_fmt_uptime(time.monotonic() - started_monotonic),
|
|
86
|
+
truncation=NO_MESSAGE,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@factory
|
|
91
|
+
def run_background_agent(
|
|
92
|
+
input: Empty, messages: list[Message], ctx: Ctx
|
|
93
|
+
) -> BackgroundAgentStatus:
|
|
94
|
+
"""Start a background agent in a daemon thread and return a heartbeat."""
|
|
95
|
+
ctx.state.checks += 1
|
|
96
|
+
checks = ctx.state.checks
|
|
97
|
+
thread = ctx.state.thread
|
|
98
|
+
if thread is not None and thread.is_alive():
|
|
99
|
+
started_monotonic = ctx.state.started_monotonic or time.monotonic()
|
|
100
|
+
return _status(
|
|
101
|
+
ctx,
|
|
102
|
+
status=_ALIVE_PHASE,
|
|
103
|
+
checks=checks,
|
|
104
|
+
started_monotonic=started_monotonic,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# First start, or the previous daemon thread has died and we respawn it.
|
|
108
|
+
status = _RESTARTED_PHASE if thread is not None else _STARTED_PHASE
|
|
109
|
+
started_monotonic = time.monotonic()
|
|
110
|
+
ctx.state.started_monotonic = started_monotonic
|
|
111
|
+
thread = threading.Thread(
|
|
112
|
+
target=_invoke_agent,
|
|
113
|
+
args=(ctx,),
|
|
114
|
+
daemon=True,
|
|
115
|
+
name=f"background-agent-{ctx.agent.name}",
|
|
116
|
+
)
|
|
117
|
+
ctx.state.thread = thread
|
|
118
|
+
logger.debug(
|
|
119
|
+
"Spawning background agent thread (name=%s, status=%s, thread=%s)",
|
|
120
|
+
ctx.agent.name,
|
|
121
|
+
status,
|
|
122
|
+
thread.name,
|
|
123
|
+
)
|
|
124
|
+
started = Event()
|
|
125
|
+
|
|
126
|
+
def signal_started(event: PipeEvent) -> None:
|
|
127
|
+
if isinstance(event, RunLifecycleEvent) and event.kind == _RUN_STARTED_KIND:
|
|
128
|
+
started.set()
|
|
129
|
+
|
|
130
|
+
unsubscribe = ctx.agent.pipe.add_sink(signal_started)
|
|
131
|
+
try:
|
|
132
|
+
thread.start()
|
|
133
|
+
if not started.wait(BACKGROUND_START_TIMEOUT_S):
|
|
134
|
+
state = "alive" if thread.is_alive() else "stopped"
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
"background agent did not persist its lifecycle event "
|
|
137
|
+
f"within {BACKGROUND_START_TIMEOUT_S:g}s (thread={state})"
|
|
138
|
+
)
|
|
139
|
+
finally:
|
|
140
|
+
unsubscribe()
|
|
141
|
+
return _status(
|
|
142
|
+
ctx, status=status, checks=checks, started_monotonic=started_monotonic
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"BackgroundAgentPhase",
|
|
148
|
+
"BackgroundAgentStatus",
|
|
149
|
+
"run_background_agent",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
run_background_agent._prepare_ctx = partial(
|
|
154
|
+
_prepare_context,
|
|
155
|
+
required=("agent",),
|
|
156
|
+
default_factories={"state": BackgroundAgentState},
|
|
157
|
+
)
|