roboshed 0.1.1.dev3__tar.gz → 0.1.1.dev4__tar.gz

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 (96) hide show
  1. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/CHANGELOG.md +13 -0
  2. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/PKG-INFO +10 -8
  3. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/README.md +8 -6
  4. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/pyproject.toml +2 -2
  5. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/agents/librarian.py +2 -2
  6. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/agents/orchestrator.py +0 -3
  7. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/capabilities.py +12 -1
  8. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/deployments/robosprawl.py +1 -3
  9. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/identifiers.py +3 -0
  10. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/robosprawl.py +4 -2
  11. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/__init__.py +4 -0
  12. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/contexts.py +9 -1
  13. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/memory_files.py +33 -8
  14. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/sleep_between_runs.py +22 -19
  15. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/snapshot_conversation_example.py +1 -1
  16. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/snapshot_conversations.py +22 -22
  17. roboshed-0.1.1.dev4/src/roboshed/tools/stop_when_watched_agents_inactive.py +50 -0
  18. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/types.py +1 -1
  19. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/memory/test_librarian.py +239 -21
  20. roboshed-0.1.1.dev4/tests/memory/test_memory_files.py +87 -0
  21. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/memory/test_snapshot_conversations.py +65 -4
  22. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_agent_deployment.py +3 -1
  23. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_agent_presets.py +2 -1
  24. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_capabilities.py +3 -2
  25. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_cli_command_chaining.py +0 -2
  26. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_cli_tools_prompt_examples.py +0 -4
  27. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_compactify_messages.py +2 -2
  28. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_compaction_runtime.py +1 -1
  29. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_deployment_graph.py +5 -2
  30. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_email_factory.py +4 -2
  31. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_generic_guards.py +2 -1
  32. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_guard.py +53 -1
  33. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_robosprawl.py +3 -4
  34. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_tool_contexts.py +14 -2
  35. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/.gitignore +0 -0
  36. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/LICENSE +0 -0
  37. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/__init__.py +0 -0
  38. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/agents/__init__.py +0 -0
  39. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/dependency_health.py +0 -0
  40. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/deployments/__init__.py +0 -0
  41. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/models.py +0 -0
  42. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/py.typed +0 -0
  43. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/sandbox.py +0 -0
  44. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/__init__.py +0 -0
  45. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/cli_tools/__init__.py +0 -0
  46. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/cli_tools/prompts.py +0 -0
  47. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/email_tools/__init__.py +0 -0
  48. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/email_tools/prompts.py +0 -0
  49. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/file_editing/__init__.py +0 -0
  50. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/skills/file_editing/prompts.py +0 -0
  51. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/_snapshot_metadata.py +0 -0
  52. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/apply_patch.py +0 -0
  53. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/__init__.py +0 -0
  54. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/run_file_command/__init__.py +0 -0
  55. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/run_file_command/command.py +0 -0
  56. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/run_file_command/resolve.py +0 -0
  57. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/run_file_command/specs.py +0 -0
  58. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/utilities/__init__.py +0 -0
  59. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/utilities/cmd_spec.py +0 -0
  60. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/utilities/constants.py +0 -0
  61. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/utilities/formatting.py +0 -0
  62. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/cli_commands/utilities/path_extractors.py +0 -0
  63. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/compactification/__init__.py +0 -0
  64. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/compactification/compactify_messages.py +0 -0
  65. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/compactification/prompts.py +0 -0
  66. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/compactification/summarize.py +0 -0
  67. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/compactification/summary_prompts.py +0 -0
  68. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/consolidate_memory.py +0 -0
  69. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/consolidate_memory_example.py +0 -0
  70. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/consolidate_memory_prompts.py +0 -0
  71. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/email/__init__.py +0 -0
  72. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/email/contracts.py +0 -0
  73. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/email/drafts.py +0 -0
  74. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/email/factory.py +0 -0
  75. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/email/inputs.py +0 -0
  76. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/email/messages.py +0 -0
  77. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/email/runtime.py +0 -0
  78. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/guard.py +0 -0
  79. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/guard_formatting.py +0 -0
  80. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/librarian_errors.py +0 -0
  81. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/purge_files.py +0 -0
  82. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/runner.py +0 -0
  83. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/snapshot_conversation_prompts.py +0 -0
  84. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/snapshot_normalize.py +0 -0
  85. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/truncation.py +0 -0
  86. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/src/roboshed/tools/utils.py +0 -0
  87. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/memory/test_consolidate_memory.py +0 -0
  88. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/memory/test_memory_prompts.py +0 -0
  89. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/memory/test_purge_files.py +0 -0
  90. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/memory/test_summarize_conversation.py +0 -0
  91. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_apply_patch.py +0 -0
  92. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_build_guarded_tool_chain.py +0 -0
  93. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_cli_command_utils.py +0 -0
  94. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_dependency_health.py +0 -0
  95. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_path_extractors.py +0 -0
  96. {roboshed-0.1.1.dev3 → roboshed-0.1.1.dev4}/tests/test_sandbox.py +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.1.dev4 - 2026-09-20
6
+
7
+ - Migrate the Librarian and capability examples to Roboz's explicit
8
+ `mode=AgentMode.DETERMINISTIC` configuration. Breaking: remove
9
+ `interaction_mode` arguments from `orchestrator` and `robosprawl` calls;
10
+ invocation uses a bound host channel, defaulting to CLI. Registering a
11
+ `UserIO` adapter selects `Output.API` for the binding's lifetime.
12
+
13
+ - Keep the Librarian active for a final maintenance sweep after watched runs become
14
+ idle, so terminal conversation facts reach snapshots and memory before it stops.
15
+ Snapshot coverage now relies only on persisted message sequences, and maintenance
16
+ artifacts are published atomically.
17
+
5
18
  ## 0.1.1.dev3 - 2026-09-13
6
19
 
7
20
  - Migrate capability bindings to the central typed contexts. Existing capability
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: roboshed
3
- Version: 0.1.1.dev3
3
+ Version: 0.1.1.dev4
4
4
  Import-Name: roboshed
5
5
  Summary: roboshed: optional building blocks for Roboz
6
6
  Project-URL: Repository, https://github.com/Tachion-Oy/roboz
@@ -11,7 +11,7 @@ Classifier: Development Status :: 2 - Pre-Alpha
11
11
  Classifier: Typing :: Typed
12
12
  Requires-Python: >=3.13
13
13
  Requires-Dist: pydantic>=2.12.4
14
- Requires-Dist: roboz<0.2.0,>=0.1.2.dev5
14
+ Requires-Dist: roboz<0.2.0,>=0.1.2.dev7
15
15
  Description-Content-Type: text/markdown
16
16
 
17
17
  # roboshed
@@ -25,7 +25,7 @@ install any model SDK, Proton, document SDK, web service, or backend framework.
25
25
 
26
26
  ```python
27
27
  from pathlib import Path
28
- from roboz import stop
28
+ from roboz.tools import stop
29
29
  from roboz.deployment import DeployableAgent, Capability
30
30
  from roboz.llm import MockLLMEndpoint
31
31
  from roboshed.capabilities import FileCommands, FileEditing
@@ -81,7 +81,7 @@ migrated. The description below records the recipe behavior to preserve.
81
81
  `roboshed.deployments.robosprawl.robosprawl` is the concrete lazy persistent
82
82
  orchestrator and Librarian recipe. Call it with an already-scoped sandbox,
83
83
  `endpoint_getter`, `memory_endpoint`, `additional_capabilities`, `specialists`,
84
- `interaction_mode`, and optional `event_sinks`. It returns a fresh root and
84
+ and optional `event_sinks`. It returns a fresh root and
85
85
  background-agent tuple.
86
86
 
87
87
  The recipe loads project memory and supplies project locations through initial
@@ -168,10 +168,12 @@ agent configuration. Each build creates fresh runtime state; applications can
168
168
  supply an agent-specific sink factory for persistence.
169
169
 
170
170
  The Librarian constructor declares its standard maintenance sequence:
171
- snapshots, consolidation, retention, then cadence. Pass its sandbox and the
172
- recursive foreground names directly to the constructor before attaching it as
173
- a background agent. The orchestrator also takes the configured sandbox and
174
- captures its permission policy when constructed.
171
+ snapshots, consolidation, retention, an idle check, then cadence. The first idle
172
+ observation schedules one complete final sweep without sleeping; the second idle
173
+ observation stops the Librarian. Pass its sandbox and the recursive foreground
174
+ names directly to the constructor before attaching it as a background agent. The
175
+ orchestrator also takes the configured sandbox and captures its permission policy
176
+ when constructed.
175
177
 
176
178
  Invoke the returned agent directly and retain the background agents for control.
177
179
  Repeated builds create fresh runtimes and bindings, but supplied endpoints,
@@ -9,7 +9,7 @@ install any model SDK, Proton, document SDK, web service, or backend framework.
9
9
 
10
10
  ```python
11
11
  from pathlib import Path
12
- from roboz import stop
12
+ from roboz.tools import stop
13
13
  from roboz.deployment import DeployableAgent, Capability
14
14
  from roboz.llm import MockLLMEndpoint
15
15
  from roboshed.capabilities import FileCommands, FileEditing
@@ -65,7 +65,7 @@ migrated. The description below records the recipe behavior to preserve.
65
65
  `roboshed.deployments.robosprawl.robosprawl` is the concrete lazy persistent
66
66
  orchestrator and Librarian recipe. Call it with an already-scoped sandbox,
67
67
  `endpoint_getter`, `memory_endpoint`, `additional_capabilities`, `specialists`,
68
- `interaction_mode`, and optional `event_sinks`. It returns a fresh root and
68
+ and optional `event_sinks`. It returns a fresh root and
69
69
  background-agent tuple.
70
70
 
71
71
  The recipe loads project memory and supplies project locations through initial
@@ -152,10 +152,12 @@ agent configuration. Each build creates fresh runtime state; applications can
152
152
  supply an agent-specific sink factory for persistence.
153
153
 
154
154
  The Librarian constructor declares its standard maintenance sequence:
155
- snapshots, consolidation, retention, then cadence. Pass its sandbox and the
156
- recursive foreground names directly to the constructor before attaching it as
157
- a background agent. The orchestrator also takes the configured sandbox and
158
- captures its permission policy when constructed.
155
+ snapshots, consolidation, retention, an idle check, then cadence. The first idle
156
+ observation schedules one complete final sweep without sleeping; the second idle
157
+ observation stops the Librarian. Pass its sandbox and the recursive foreground
158
+ names directly to the constructor before attaching it as a background agent. The
159
+ orchestrator also takes the configured sandbox and captures its permission policy
160
+ when constructed.
159
161
 
160
162
  Invoke the returned agent directly and retain the background agents for control.
161
163
  Repeated builds create fresh runtimes and bindings, but supplied endpoints,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "roboshed"
3
- version = "0.1.1.dev3"
3
+ version = "0.1.1.dev4"
4
4
  description = "roboshed: optional building blocks for Roboz"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -9,7 +9,7 @@ license = "Apache-2.0"
9
9
  license-files = ["LICENSE"]
10
10
  import-names = ["roboshed"]
11
11
  classifiers = ["Development Status :: 2 - Pre-Alpha", "Typing :: Typed"]
12
- dependencies = ["roboz>=0.1.2.dev5,<0.2.0","pydantic>=2.12.4"]
12
+ dependencies = ["roboz>=0.1.2.dev7,<0.2.0","pydantic>=2.12.4"]
13
13
 
14
14
  [build-system]
15
15
  requires = ["hatchling>=1.32"]
@@ -13,6 +13,7 @@ from roboshed.identifiers import LIBRARIAN_AGENT_NAME
13
13
  from roboshed.sandbox import Sandbox
14
14
  from roboz.deployment import DeployableAgent
15
15
  from roboz.llm import EndpointLike
16
+ from roboz.models import AgentMode
16
17
 
17
18
  LIBRARIAN_AGENT_DESCRIPTION: Final[str] = (
18
19
  "Runs deterministic maintenance cycles using the configured capabilities."
@@ -29,7 +30,7 @@ def librarian(
29
30
  agent = DeployableAgent(
30
31
  name=LIBRARIAN_AGENT_NAME,
31
32
  description=LIBRARIAN_AGENT_DESCRIPTION,
32
- is_agentic=False,
33
+ mode=AgentMode.DETERMINISTIC,
33
34
  automatic_tool_prompt=False,
34
35
  default_capabilities=(
35
36
  ConversationSnapshots(),
@@ -39,7 +40,6 @@ def librarian(
39
40
  ),
40
41
  )
41
42
  agent.set_agent_endpoint(agent_endpoint)
42
- agent.set_interaction_mode(None)
43
43
  agent.set_attributes(
44
44
  sandbox=sandbox,
45
45
  watched_agent_names=frozenset(watched_agent_names),
@@ -7,7 +7,6 @@ from roboshed.capabilities import FileCommands, FileEditing
7
7
  from roboshed.sandbox import Sandbox
8
8
  from roboz.deployment import Capability, DeployableAgent
9
9
  from roboz.llm import EndpointLike
10
- from roboz.runtime import Output
11
10
  from roboz.tools import stop
12
11
 
13
12
  ORCHESTRATOR_PROMPT = """You are the user's persistent collaborator. Help them reach their
@@ -26,7 +25,6 @@ def orchestrator(
26
25
  agent_endpoint: EndpointLike,
27
26
  subagents: Sequence[DeployableAgent] = (),
28
27
  background_agents: Sequence[DeployableAgent] = (),
29
- interaction_mode: Output | None = Output.CLI,
30
28
  initial_messages: Sequence[Path | str] = (),
31
29
  ) -> DeployableAgent:
32
30
  """Configure the orchestrator for an already-configured sandbox."""
@@ -43,7 +41,6 @@ def orchestrator(
43
41
  background_agents=tuple(background_agents),
44
42
  )
45
43
  agent.set_agent_endpoint(agent_endpoint)
46
- agent.set_interaction_mode(interaction_mode)
47
44
  agent.set_initial_messages(initial_messages)
48
45
  agent.set_attributes(permissions=sandbox.permissions())
49
46
  return agent
@@ -12,6 +12,7 @@ from roboshed.identifiers import (
12
12
  PURGE_SNAPSHOTS_TOOL_NAME,
13
13
  SLEEP_BETWEEN_RUNS_TOOL_NAME,
14
14
  SNAPSHOT_CONVERSATIONS_TOOL_NAME,
15
+ STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME,
15
16
  )
16
17
  from roboshed.skills import cli_skill, file_editing
17
18
  from roboshed.tools import (
@@ -30,9 +31,13 @@ from roboshed.tools.contexts import (
30
31
  PurgeFilesContext,
31
32
  SleepBetweenRunsContext,
32
33
  SnapshotConversationsContext,
34
+ StopWhenWatchedAgentsInactiveContext,
33
35
  )
34
36
  from roboshed.tools.purge_files import purge_files
35
37
  from roboshed.tools.sleep_between_runs import sleep_between_runs
38
+ from roboshed.tools.stop_when_watched_agents_inactive import (
39
+ stop_when_watched_agents_inactive,
40
+ )
36
41
  from roboshed.tools.snapshot_conversations import snapshot_conversations
37
42
  from roboshed.sandbox import PermissionPolicy, Sandbox
38
43
  from roboz.deployment import (
@@ -326,11 +331,17 @@ class MaintenanceCadence(AgentCapability):
326
331
  }
327
332
 
328
333
  def build(self, agent: DeployableAgent, pipe: EventPipe) -> Capability:
329
- """Bind cadence and idle stopping to this agent's cancellation state."""
334
+ """Bind cadence and inactive-agent stopping to cancellation state."""
330
335
  sandbox = cast(Sandbox, agent.sandbox)
331
336
  watched_agent_names = cast(Collection[str], agent.watched_agent_names)
332
337
  return Capability(
333
338
  default_tools=(
339
+ stop_when_watched_agents_inactive(
340
+ StopWhenWatchedAgentsInactiveContext(
341
+ conversation_root=sandbox.project_logs_dir(),
342
+ agent_names=set(watched_agent_names),
343
+ )
344
+ ).copy(name=STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME),
334
345
  sleep_between_runs(
335
346
  SleepBetweenRunsContext(
336
347
  seconds=self.seconds,
@@ -8,7 +8,7 @@ from roboshed.sandbox import Sandbox
8
8
  from roboz.agent import Agent
9
9
  from roboz.deployment import AgentCapability, DeployableAgent
10
10
  from roboz.llm import EndpointLike, LLMEndpoint, LLMEndpointRoute
11
- from roboz.runtime import EventSink, Output, default_event_sinks
11
+ from roboz.runtime import EventSink, default_event_sinks
12
12
 
13
13
 
14
14
  def robosprawl(
@@ -19,7 +19,6 @@ def robosprawl(
19
19
  memory_endpoint: EndpointLike,
20
20
  additional_capabilities: Sequence[AgentCapability],
21
21
  specialists: Sequence[DeployableAgent],
22
- interaction_mode: Output | None,
23
22
  event_sinks: Sequence[EventSink] = (),
24
23
  ) -> tuple[Agent, tuple[Agent, ...]]:
25
24
  """Build a fresh fixed orchestrator and Librarian for a scoped project.
@@ -39,7 +38,6 @@ def robosprawl(
39
38
  sandbox,
40
39
  agent_endpoint=LLMEndpointRoute(endpoint_getter),
41
40
  subagents=tuple(specialists),
42
- interaction_mode=interaction_mode,
43
41
  )
44
42
  root.add_capabilities(*additional_capabilities)
45
43
  watched_agent_names = root.agent_names(include_background=False)
@@ -23,5 +23,8 @@ PURGE_FILES_TOOL_NAME: Final[str] = "purge_files"
23
23
  PURGE_LOGS_TOOL_NAME: Final[str] = "purge_logs"
24
24
  PURGE_SNAPSHOTS_TOOL_NAME: Final[str] = "purge_snapshots"
25
25
  PURGE_MEMORY_TOOL_NAME: Final[str] = "purge_memory"
26
+ STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME: Final[str] = (
27
+ "stop_when_watched_agents_inactive"
28
+ )
26
29
  SLEEP_BETWEEN_RUNS_TOOL_NAME: Final[str] = "sleep_between_runs"
27
30
  LIBRARIAN_AGENT_NAME: Final[str] = "librarian"
@@ -56,8 +56,10 @@ project directory. Use the supplied paths instead of guessing folder names.
56
56
 
57
57
  The Librarian is a deterministic background pipeline. It snapshots conversations,
58
58
  consolidates memory, applies retention limits, and waits between maintenance cycles.
59
- The root starts it through its background-start tool; the host owns cancellation
60
- and shutdown. Maintenance is asynchronous, so a new conversation may not yet have
59
+ After watched work becomes idle, it performs one complete final sweep without
60
+ sleeping and stops only after confirming that the project remains idle. The root
61
+ starts it through its background-start tool; the host owns cancellation and
62
+ shutdown. Maintenance is asynchronous, so a new conversation may not yet have
61
63
  appeared in memory. Conversation logs are written by the runtime.
62
64
 
63
65
  Do not duplicate that maintenance by writing your own session summaries or memory
@@ -12,6 +12,7 @@ from .contexts import (
12
12
  PurgeFilesContext,
13
13
  SleepBetweenRunsContext,
14
14
  SnapshotConversationsContext,
15
+ StopWhenWatchedAgentsInactiveContext,
15
16
  )
16
17
  from .runner import ExecutableCommandCatalog
17
18
  from .cli_commands.run_file_command import get_run_file_command
@@ -19,6 +20,7 @@ from .compactification import get_compactify_messages_when_needed_tool
19
20
  from .consolidate_memory import consolidate_memory
20
21
  from .purge_files import purge_files, purge_files_by_threshold
21
22
  from .sleep_between_runs import sleep_between_runs
23
+ from .stop_when_watched_agents_inactive import stop_when_watched_agents_inactive
22
24
  from .snapshot_conversations import (
23
25
  SnapshotMode,
24
26
  snapshot_conversations,
@@ -36,6 +38,7 @@ __all__ = [
36
38
  "SnapshotConversationsContext",
37
39
  "PurgeFilesContext",
38
40
  "SleepBetweenRunsContext",
41
+ "StopWhenWatchedAgentsInactiveContext",
39
42
  "SnapshotMode",
40
43
  "consolidate_memory",
41
44
  "get_apply_patch",
@@ -44,5 +47,6 @@ __all__ = [
44
47
  "purge_files",
45
48
  "purge_files_by_threshold",
46
49
  "sleep_between_runs",
50
+ "stop_when_watched_agents_inactive",
47
51
  "snapshot_conversations",
48
52
  ]
@@ -137,9 +137,17 @@ class PurgeFilesContext:
137
137
  prune_empty_directories: bool = False
138
138
 
139
139
 
140
+ @dataclass(frozen=True, kw_only=True)
141
+ class StopWhenWatchedAgentsInactiveContext:
142
+ """Watched conversations used to decide when maintenance may stop."""
143
+
144
+ conversation_root: Path
145
+ agent_names: set[str]
146
+
147
+
140
148
  @dataclass(frozen=True, kw_only=True)
141
149
  class SleepBetweenRunsContext:
142
- """Wait duration, cancellation callback, and watched conversations."""
150
+ """Wait settings for observing activity between maintenance cycles."""
143
151
 
144
152
  seconds: float
145
153
  is_cancelled: Callable[[], bool] | None = None
@@ -1,8 +1,11 @@
1
1
  """Filesystem helpers shared by snapshot and consolidation tools."""
2
2
 
3
- from datetime import UTC, datetime
3
+ import os
4
+ from datetime import UTC, datetime, timedelta
5
+ from itertools import count
4
6
  from pathlib import Path
5
7
  from typing import Final
8
+ from uuid import uuid4
6
9
 
7
10
  from pydantic import ValidationError
8
11
 
@@ -67,15 +70,37 @@ def write_timestamped_file(
67
70
  replace: Path | None,
68
71
  pipe: EventPipe | None = None,
69
72
  ) -> Path:
70
- """Write a new timestamp-named artifact, then optionally delete ``replace``."""
71
- path = folder / f"{utc_now().strftime(TIMESTAMP_STEM_FORMAT)}{suffix}"
72
- path.parent.mkdir(parents=True, exist_ok=True)
73
- if pipe is not None:
74
- pipe.raise_if_cancelled()
75
- path.write_text(body, encoding=UTF8_ENCODING)
73
+ """Atomically publish a complete timestamped artifact, then remove ``replace``."""
74
+ folder.mkdir(parents=True, exist_ok=True)
75
+ temporary_path = folder / f".librarian-{uuid4().hex}.tmp"
76
+ published_path: Path | None = None
77
+ try:
78
+ with temporary_path.open("x", encoding=UTF8_ENCODING) as temporary:
79
+ temporary.write(body)
80
+ temporary.flush()
81
+ os.fsync(temporary.fileno())
82
+
83
+ if pipe is not None:
84
+ pipe.raise_if_cancelled()
85
+
86
+ base_time = utc_now()
87
+ for offset in count():
88
+ stamp = base_time + timedelta(microseconds=offset)
89
+ path = folder / f"{stamp.strftime(TIMESTAMP_STEM_FORMAT)}{suffix}"
90
+ try:
91
+ os.link(temporary_path, path)
92
+ except FileExistsError:
93
+ continue
94
+ published_path = path
95
+ break
96
+ finally:
97
+ temporary_path.unlink(missing_ok=True)
98
+
99
+ if published_path is None:
100
+ raise RuntimeError("timestamp candidate generation exhausted")
76
101
  if replace is not None:
77
102
  replace.unlink(missing_ok=True)
78
- return path
103
+ return published_path
79
104
 
80
105
 
81
106
  __all__ = [
@@ -1,23 +1,18 @@
1
1
  """Cancellable wait between deterministic Librarian maintenance cycles."""
2
2
 
3
3
  from collections.abc import Callable
4
- from pathlib import Path
5
4
  from time import sleep
6
5
  from typing import Final
7
6
 
8
7
  from roboshed.identifiers import SLEEP_BETWEEN_RUNS_TOOL_NAME
8
+ from roboshed.tools.contexts import SleepBetweenRunsContext
9
9
  from roboz.exceptions import ExternalCallCancelledError
10
- from roboz.models import NO_MESSAGE, All, Message, Stop, Str
10
+ from roboz.models import NO_MESSAGE, All, Message, Str
11
11
  from roboz.runtime.persistence import active_marker_paths
12
- from roboshed.tools.contexts import SleepBetweenRunsContext
13
12
  from roboz.tooling.decorators import factory
14
13
 
15
14
  SLEEP_POLL_SECONDS: Final[float] = 1.0
16
15
 
17
- _PROJECT_IDLE_STATUS: Final[str] = "project idle"
18
- _ACTIVE_RUN_ENDED_STATUS: Final[str] = "active run ended"
19
- _MIN_SLEEP_SECONDS: Final[float] = 0.0
20
-
21
16
 
22
17
  def _raise_if_cancelled(is_cancelled: Callable[[], bool] | None) -> None:
23
18
  if is_cancelled is not None and is_cancelled():
@@ -27,19 +22,27 @@ def _raise_if_cancelled(is_cancelled: Callable[[], bool] | None) -> None:
27
22
  @factory
28
23
  def sleep_between_runs(
29
24
  input: All, messages: list[Message], ctx: SleepBetweenRunsContext
30
- ) -> Str | Stop:
31
- """Wait for the next cycle, or stop once the watched project becomes idle."""
25
+ ) -> Str:
26
+ """Wait between maintenance cycles, waking promptly when a watched run ends.
27
+
28
+ Return immediately when the watched agents are inactive. This tool never
29
+ decides whether maintenance is complete; use ``stop_when_watched_agents_inactive`` for that.
30
+ """
32
31
  del input, messages
33
- seconds = max(_MIN_SLEEP_SECONDS, float(ctx.seconds))
34
32
  _raise_if_cancelled(ctx.is_cancelled)
35
-
36
- active_markers: tuple[Path, ...] = ()
37
- if ctx.conversation_root is not None:
38
- active_markers = active_marker_paths(ctx.conversation_root, ctx.agent_names)
39
- if not active_markers:
40
- return Stop(value=f"{SLEEP_BETWEEN_RUNS_TOOL_NAME}: {_PROJECT_IDLE_STATUS}")
41
-
42
- slept = _MIN_SLEEP_SECONDS
33
+ active_markers = (
34
+ active_marker_paths(ctx.conversation_root, ctx.agent_names)
35
+ if ctx.conversation_root is not None
36
+ else ()
37
+ )
38
+ if ctx.conversation_root is not None and not active_markers:
39
+ return Str(
40
+ value=f"{SLEEP_BETWEEN_RUNS_TOOL_NAME}: watched agents inactive",
41
+ truncation=NO_MESSAGE,
42
+ )
43
+
44
+ seconds = max(0.0, float(ctx.seconds))
45
+ slept = 0.0
43
46
  while slept < seconds:
44
47
  _raise_if_cancelled(ctx.is_cancelled)
45
48
  chunk = min(SLEEP_POLL_SECONDS, seconds - slept)
@@ -48,7 +51,7 @@ def sleep_between_runs(
48
51
  _raise_if_cancelled(ctx.is_cancelled)
49
52
  if any(not marker.exists() for marker in active_markers):
50
53
  return Str(
51
- value=(f"{SLEEP_BETWEEN_RUNS_TOOL_NAME}: {_ACTIVE_RUN_ENDED_STATUS}"),
54
+ value=f"{SLEEP_BETWEEN_RUNS_TOOL_NAME}: active run ended",
52
55
  truncation=NO_MESSAGE,
53
56
  )
54
57
  return Str(
@@ -3,7 +3,7 @@
3
3
  from typing import Final
4
4
 
5
5
  SNAPSHOT_CONVERSATION_EXAMPLE: Final[str] = """## Goals and intent
6
- - User wants a non-agentic Librarian that runs in the background and maintains conversation memory without user involvement.
6
+ - User wants a deterministic Librarian that runs in the background and maintains conversation memory without user involvement.
7
7
  - User stated the snapshots exist to feed a later consolidation step, "the initial raw phase of information gathering".
8
8
 
9
9
  ## State of work
@@ -3,6 +3,7 @@
3
3
  import logging
4
4
  from datetime import datetime
5
5
  from enum import StrEnum
6
+ from pathlib import Path
6
7
  from typing import Final
7
8
 
8
9
  from roboshed.identifiers import SNAPSHOT_CONVERSATIONS_TOOL_NAME
@@ -47,8 +48,6 @@ TERMINAL_SYNCABLE_STATUSES: Final[frozenset[RunStatus]] = frozenset(
47
48
  {RunStatus.COMPLETED, RunStatus.FAILED}
48
49
  )
49
50
 
50
- _ISO_Z_SUFFIX: Final[str] = "Z"
51
- _ISO_UTC_OFFSET: Final[str] = "+00:00"
52
51
  _NOT_ENDED: Final[str] = "not ended"
53
52
  _UNKNOWN_TIME: Final[str] = "unknown"
54
53
  _SOURCE_AGENT_KEY: Final[str] = "source_agent"
@@ -69,29 +68,28 @@ class SnapshotMode(StrEnum):
69
68
 
70
69
  def _uncovered_rows(
71
70
  rows: list[LoggedMessageRow],
72
- latest_snapshot_time: datetime | None,
73
71
  snapshot_document: SnapshotDocument | None,
74
72
  ) -> list[LoggedMessageRow]:
75
- """Return rows beyond the stored cursor, with timestamp fallback for legacy files."""
73
+ """Return rows beyond valid stored coverage, reprocessing legacy artifacts."""
76
74
  if snapshot_document is not None and snapshot_document.coverage_marker_present:
77
75
  covered_sequence = snapshot_document.covered_through_sequence
78
76
  if covered_sequence is None:
79
77
  return rows
80
78
  return [row for row in rows if row.sequence > covered_sequence]
81
- if latest_snapshot_time is None:
82
- return rows
83
- for index, row in enumerate(rows):
84
- try:
85
- created_at = datetime.fromisoformat(
86
- row.created_at.replace(_ISO_Z_SUFFIX, _ISO_UTC_OFFSET)
87
- )
88
- except ValueError:
89
- return rows[index:]
90
- if created_at.tzinfo is None:
91
- return rows[index:]
92
- if created_at > latest_snapshot_time:
93
- return rows[index:]
94
- return []
79
+ return rows
80
+
81
+
82
+ def _persisted_snapshot_covers(path: Path, *, sequence: int) -> bool:
83
+ """Return whether a competing snapshot has valid sufficient coverage."""
84
+ try:
85
+ document = parse_snapshot_document(path.read_text(encoding=UTF8_ENCODING))
86
+ except OSError:
87
+ return False
88
+ return (
89
+ document.coverage_marker_present
90
+ and document.covered_through_sequence is not None
91
+ and document.covered_through_sequence >= sequence
92
+ )
95
93
 
96
94
 
97
95
  def _normalize_temporal_messages(
@@ -191,9 +189,7 @@ def _snapshot_one_run(
191
189
  if snapshot_location is not None
192
190
  else None
193
191
  )
194
- uncovered_rows = _uncovered_rows(
195
- list(run.messages), snapshot_time, snapshot_document
196
- )
192
+ uncovered_rows = _uncovered_rows(list(run.messages), snapshot_document)
197
193
  if not uncovered_rows:
198
194
  return False
199
195
  covered_through_sequence = max(row.sequence for row in uncovered_rows)
@@ -235,9 +231,13 @@ def _snapshot_one_run(
235
231
  if ctx.pipe is not None:
236
232
  ctx.pipe.raise_if_cancelled()
237
233
 
238
- current_time, _ = latest_timestamped_file(snapshot_folder, suffix=MARKDOWN_SUFFIX)
234
+ current_time, current_location = latest_timestamped_file(
235
+ snapshot_folder, suffix=MARKDOWN_SUFFIX
236
+ )
239
237
  if current_time is not None and (
240
238
  snapshot_time is None or current_time > snapshot_time
239
+ ) and current_location is not None and _persisted_snapshot_covers(
240
+ current_location, sequence=covered_through_sequence
241
241
  ):
242
242
  return False
243
243
 
@@ -0,0 +1,50 @@
1
+ """Require a final maintenance sweep after watched agents become inactive."""
2
+
3
+ import json
4
+
5
+ from roboshed.identifiers import STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME
6
+ from roboshed.tools.contexts import StopWhenWatchedAgentsInactiveContext
7
+ from roboz.models import NO_MESSAGE, All, Message, Stop, Str, filter_messages
8
+ from roboz.runtime.persistence import active_marker_paths
9
+ from roboz.tooling.decorators import factory
10
+
11
+ _FINAL_SWEEP_REQUIRED = (
12
+ f"{STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME}: final sweep required"
13
+ )
14
+
15
+
16
+ @factory
17
+ def stop_when_watched_agents_inactive(
18
+ input: All, messages: list[Message], ctx: StopWhenWatchedAgentsInactiveContext
19
+ ) -> Str | Stop:
20
+ """Stop only after watched agents remain inactive for a full final sweep."""
21
+ del input
22
+ if active_marker_paths(ctx.conversation_root, ctx.agent_names):
23
+ return Str(
24
+ value=f"{STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME}: watched agents active",
25
+ truncation=NO_MESSAGE,
26
+ )
27
+
28
+ previous_checks = filter_messages(
29
+ messages, caller=STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME
30
+ )
31
+ last_result = (
32
+ json.loads(previous_checks[-1].content).get("value")
33
+ if previous_checks
34
+ else None
35
+ )
36
+ if last_result == _FINAL_SWEEP_REQUIRED:
37
+ # A complete maintenance sweep has run since the previous inactive check.
38
+ return Stop(
39
+ value=f"{STOP_WHEN_WATCHED_AGENTS_INACTIVE_TOOL_NAME}: watched agents inactive"
40
+ )
41
+
42
+ # The preceding sweep may have missed a run's final output. Now that the
43
+ # watched agents are inactive, require another sweep before allowing shutdown.
44
+ return Str(
45
+ value=_FINAL_SWEEP_REQUIRED,
46
+ truncation=NO_MESSAGE,
47
+ )
48
+
49
+
50
+ __all__ = ["StopWhenWatchedAgentsInactiveContext", "stop_when_watched_agents_inactive"]
@@ -10,7 +10,7 @@ from roboshed.models import (
10
10
  TInput,
11
11
  TPayload,
12
12
  )
13
- from roboz import Empty
13
+ from roboz.models import Empty
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  pass