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/__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
@@ -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"
@@ -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
+ )