steerable-agent-runtime 0.6.3__tar.gz → 0.6.4__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 (144) hide show
  1. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/PKG-INFO +1 -1
  2. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/pyproject.toml +1 -1
  3. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/__init__.py +12 -0
  4. steerable_agent_runtime-0.6.4/src/steerable_agent_runtime/todo.py +213 -0
  5. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
  6. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime.egg-info/SOURCES.txt +2 -0
  7. steerable_agent_runtime-0.6.4/tests/test_todo.py +156 -0
  8. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/README.md +0 -0
  9. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/setup.cfg +0 -0
  10. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/antihallucination.py +0 -0
  11. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/approval.py +0 -0
  12. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/approval_policy.py +0 -0
  13. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/ask_user.py +0 -0
  14. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/branch.py +0 -0
  15. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/cache_control.py +0 -0
  16. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/calibration.py +0 -0
  17. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/compaction.py +0 -0
  18. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/config.py +0 -0
  19. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/default.harness.json +0 -0
  20. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/default.harness.yaml +0 -0
  21. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/errors.py +0 -0
  22. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/handoff.py +0 -0
  23. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/harness.py +0 -0
  24. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/harness_spec.py +0 -0
  25. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/history.py +0 -0
  26. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/hooks.py +0 -0
  27. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/__init__.py +0 -0
  28. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
  29. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/compat.py +0 -0
  30. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/errors.py +0 -0
  31. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/openai_compat.py +0 -0
  32. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/parts.py +0 -0
  33. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/presets.py +0 -0
  34. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
  35. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/loop.py +0 -0
  36. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/maintenance.py +0 -0
  37. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/mcp.py +0 -0
  38. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/mcp_server.py +0 -0
  39. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/model_catalog.py +0 -0
  40. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/model_info.py +0 -0
  41. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/model_resolve.py +0 -0
  42. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/observation_aging.py +0 -0
  43. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/orchestration.py +0 -0
  44. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/otel.py +0 -0
  45. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/plugins.py +0 -0
  46. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/pool.py +0 -0
  47. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/pricing.py +0 -0
  48. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/pseudo.py +0 -0
  49. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/recording.py +0 -0
  50. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/reminders.py +0 -0
  51. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/replay.py +0 -0
  52. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/resume.py +0 -0
  53. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/retry.py +0 -0
  54. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/sandboxed.py +0 -0
  55. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/skills.py +0 -0
  56. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/spill.py +0 -0
  57. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/storage/__init__.py +0 -0
  58. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
  59. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
  60. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/storage/sqlite_store.py +0 -0
  61. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
  62. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/subagent.py +0 -0
  63. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/tokens.py +0 -0
  64. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/tool_schema.py +0 -0
  65. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/tool_search.py +0 -0
  66. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/tools.py +0 -0
  67. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/tracing.py +0 -0
  68. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/transport/__init__.py +0 -0
  69. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
  70. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
  71. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime/world_state.py +0 -0
  72. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
  73. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
  74. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
  75. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_antihallucination.py +0 -0
  76. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_approval.py +0 -0
  77. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_approval_policy.py +0 -0
  78. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_ask_user.py +0 -0
  79. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_branch.py +0 -0
  80. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_cache_control.py +0 -0
  81. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_cache_instrumentation.py +0 -0
  82. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_calibration.py +0 -0
  83. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_compaction.py +0 -0
  84. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_config.py +0 -0
  85. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_content_parts.py +0 -0
  86. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_error_taxonomy.py +0 -0
  87. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_fragment_bounds.py +0 -0
  88. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_golden.py +0 -0
  89. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_handoff.py +0 -0
  90. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_harness.py +0 -0
  91. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_harness_spec.py +0 -0
  92. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_history.py +0 -0
  93. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_history_persistence.py +0 -0
  94. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_hooks.py +0 -0
  95. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_in_memory_storage.py +0 -0
  96. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_llm_wire_helpers.py +0 -0
  97. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_long_session.py +0 -0
  98. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_loop.py +0 -0
  99. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_loop_cancellation.py +0 -0
  100. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_loop_replay.py +0 -0
  101. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_loop_sandbox_event.py +0 -0
  102. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_maintenance.py +0 -0
  103. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_mcp.py +0 -0
  104. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_mcp_server.py +0 -0
  105. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_model_catalog.py +0 -0
  106. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_model_equivalence.py +0 -0
  107. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_model_info.py +0 -0
  108. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_model_resolve.py +0 -0
  109. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_observation_aging.py +0 -0
  110. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_orchestration.py +0 -0
  111. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_otel.py +0 -0
  112. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_parallel_tools.py +0 -0
  113. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_plugins.py +0 -0
  114. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_provider_compat.py +0 -0
  115. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_provider_presets.py +0 -0
  116. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_pseudo.py +0 -0
  117. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_recording.py +0 -0
  118. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_reminders.py +0 -0
  119. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_replay_crosslang.py +0 -0
  120. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_resume.py +0 -0
  121. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_retry_hooks.py +0 -0
  122. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_safety_gate.py +0 -0
  123. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_sandboxed.py +0 -0
  124. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_skills.py +0 -0
  125. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_soft_timeout.py +0 -0
  126. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_spill.py +0 -0
  127. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_sqlite_storage.py +0 -0
  128. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_steer.py +0 -0
  129. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_storage_contract.py +0 -0
  130. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_stream_strip.py +0 -0
  131. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_subagent.py +0 -0
  132. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_system_proxy.py +0 -0
  133. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_tokens.py +0 -0
  134. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_tool_exposure.py +0 -0
  135. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_tool_hygiene.py +0 -0
  136. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_tool_router.py +0 -0
  137. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_tool_schema.py +0 -0
  138. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_tool_timeout.py +0 -0
  139. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_trace_recorder.py +0 -0
  140. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_transport_jsonrpc.py +0 -0
  141. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_transport_sse.py +0 -0
  142. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_usage_attribution.py +0 -0
  143. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_world_state.py +0 -0
  144. {steerable_agent_runtime-0.6.3 → steerable_agent_runtime-0.6.4}/tests/test_write_lease.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.6.3
3
+ Version: 0.6.4
4
4
  Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerable-agent-runtime"
3
- version = "0.6.3"
3
+ version = "0.6.4"
4
4
  description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -35,6 +35,13 @@ from .ask_user import (
35
35
  AskUserHandler,
36
36
  make_ask_user_tool,
37
37
  )
38
+ from .todo import (
39
+ TODO_SCHEMA,
40
+ TODO_TOOL_NAME,
41
+ TodoStore,
42
+ make_todo_write_tool,
43
+ todo_write_tool_descriptor,
44
+ )
38
45
  from .config import (
39
46
  DEFAULT_CONFIG_PATH,
40
47
  ConfigError,
@@ -262,6 +269,8 @@ __all__ = [
262
269
  "TOOL_SEARCH_NAME",
263
270
  "ASK_USER_SCHEMA",
264
271
  "ASK_USER_TOOL_NAME",
272
+ "TODO_SCHEMA",
273
+ "TODO_TOOL_NAME",
265
274
  "STEERABLE_TOOLS_ENTRY_POINT_GROUP",
266
275
  "AgentPool",
267
276
  "AntiHallucinationConfig",
@@ -382,6 +391,7 @@ __all__ = [
382
391
  "ToolExecutor",
383
392
  "ToolExposure",
384
393
  "ToolRouter",
394
+ "TodoStore",
385
395
  "TraceRecorder",
386
396
  "TranscriptAppend",
387
397
  "TransportAdapter",
@@ -423,6 +433,7 @@ __all__ = [
423
433
  "load_recorded_requests",
424
434
  "load_transcript",
425
435
  "make_ask_user_tool",
436
+ "make_todo_write_tool",
426
437
  "matches_conditions",
427
438
  "mcp_invoker",
428
439
  "merge_patch",
@@ -456,6 +467,7 @@ __all__ = [
456
467
  "system_blocks_with_cache",
457
468
  "text_parts",
458
469
  "to_otlp_json",
470
+ "todo_write_tool_descriptor",
459
471
  "tool",
460
472
  "tool_search_descriptor",
461
473
  "upgrade_entry_dict",
@@ -0,0 +1,213 @@
1
+ """Session task list — the ``todo_write`` tool.
2
+
3
+ The model maintains one ordered task list per chat (Claude Code
4
+ ``TodoWrite`` parity): multi-step work stays legible across rounds because
5
+ the model rewrites the list as it progresses, and every rewrite lands in the
6
+ durable message record as the tool result, so a resumed session rebuilds the
7
+ list from the transcript.
8
+
9
+ Semantics are full-replace: each call carries the complete list, not a diff.
10
+ The store keys lists by ``chat_id`` from the dispatch context, so concurrent
11
+ chats in one sidecar process never see each other's tasks.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from .errors import ToolDispatchError
19
+
20
+ #: The tool name the model calls.
21
+ TODO_TOOL_NAME = "todo_write"
22
+
23
+ #: Valid task states, in lifecycle order.
24
+ TODO_STATUSES = ("pending", "in_progress", "completed")
25
+
26
+ #: Bounds on the list itself. The list is a working set, not a backlog —
27
+ #: past ~50 items the model is planning, not tracking, and the rendered
28
+ #: result starts to lean on the fragment budget.
29
+ _MIN_TODOS = 1
30
+ _MAX_TODOS = 50
31
+
32
+ #: Model-facing description, shared by the registration metadata and the
33
+ #: OpenAI descriptor.
34
+ _DESCRIPTION = (
35
+ "Track multi-step work as an ordered task list. Create the list when a "
36
+ "task needs several steps; rewrite it as you progress — mark the task "
37
+ "you are working on in_progress (at most one) and completed the moment "
38
+ "it is done. Each call replaces the whole list. Skip it for single-step "
39
+ "requests."
40
+ )
41
+
42
+ #: JSON Schema for the tool's arguments — one full-replace list.
43
+ TODO_SCHEMA: dict[str, Any] = {
44
+ "type": "object",
45
+ "properties": {
46
+ "todos": {
47
+ "type": "array",
48
+ "minItems": _MIN_TODOS,
49
+ "maxItems": _MAX_TODOS,
50
+ "items": {
51
+ "type": "object",
52
+ "properties": {
53
+ "id": {
54
+ "type": "string",
55
+ "description": "Stable identifier for this task.",
56
+ },
57
+ "content": {
58
+ "type": "string",
59
+ "description": "One line describing the task.",
60
+ },
61
+ "status": {
62
+ "type": "string",
63
+ "enum": list(TODO_STATUSES),
64
+ },
65
+ },
66
+ "required": ["id", "content", "status"],
67
+ },
68
+ "description": (
69
+ "The complete task list, replacing the previous one. "
70
+ "Mark exactly the task you are working on as in_progress; "
71
+ "keep at most one in_progress at a time."
72
+ ),
73
+ },
74
+ },
75
+ "required": ["todos"],
76
+ }
77
+
78
+
79
+ class TodoStore:
80
+ """Process-local task lists keyed by chat id.
81
+
82
+ Persistence comes for free from the message record — every rewrite is a
83
+ tool result in the transcript, and full-replace semantics make the state
84
+ self-healing: after a restart the model's next call rewrites the whole
85
+ list from what it sees in the resumed history.
86
+ """
87
+
88
+ def __init__(self) -> None:
89
+ self._by_chat: dict[str, list[dict[str, Any]]] = {}
90
+
91
+ def get(self, chat_id: str) -> list[dict[str, Any]]:
92
+ return [dict(item) for item in self._by_chat.get(chat_id, [])]
93
+
94
+ def set(self, chat_id: str, todos: list[dict[str, Any]]) -> None:
95
+ self._by_chat[chat_id] = [dict(item) for item in todos]
96
+
97
+
98
+ def _normalize_todos(todos: Any) -> list[dict[str, Any]]:
99
+ """Validate one full-replace list and return it in canonical shape.
100
+
101
+ A violation raises ``ToolDispatchError`` — the router wraps it as the
102
+ tool result, so the model sees exactly what to fix and retries within
103
+ the same turn.
104
+ """
105
+ if not isinstance(todos, list):
106
+ raise ToolDispatchError(
107
+ f"todo_write: todos must be an array, got {type(todos).__name__}"
108
+ )
109
+ if not (_MIN_TODOS <= len(todos) <= _MAX_TODOS):
110
+ raise ToolDispatchError(
111
+ f"todo_write: todos must contain {_MIN_TODOS}-{_MAX_TODOS} items, "
112
+ f"got {len(todos)}. Track the current working set, not a backlog."
113
+ )
114
+ seen_ids: set[str] = set()
115
+ in_progress = 0
116
+ normalized: list[dict[str, Any]] = []
117
+ for index, item in enumerate(todos):
118
+ if not isinstance(item, dict):
119
+ raise ToolDispatchError(
120
+ f"todo_write: todos[{index}] must be an object, "
121
+ f"got {type(item).__name__}"
122
+ )
123
+ task_id = item.get("id")
124
+ if not isinstance(task_id, str) or not task_id:
125
+ raise ToolDispatchError(
126
+ f"todo_write: todos[{index}] is missing a non-empty string "
127
+ '"id".'
128
+ )
129
+ if task_id in seen_ids:
130
+ raise ToolDispatchError(
131
+ f'todo_write: duplicate id "{task_id}" — ids must be unique.'
132
+ )
133
+ seen_ids.add(task_id)
134
+ content = item.get("content")
135
+ if not isinstance(content, str) or not content.strip():
136
+ raise ToolDispatchError(
137
+ f'todo_write: todos[{index}] ("{task_id}") is missing a '
138
+ 'non-empty string "content".'
139
+ )
140
+ status = item.get("status")
141
+ if status not in TODO_STATUSES:
142
+ raise ToolDispatchError(
143
+ f'todo_write: todos[{index}] ("{task_id}") has invalid status '
144
+ f"{status!r}; expected one of {', '.join(TODO_STATUSES)}."
145
+ )
146
+ if status == "in_progress":
147
+ in_progress += 1
148
+ normalized.append({"id": task_id, "content": content, "status": status})
149
+ if in_progress > 1:
150
+ raise ToolDispatchError(
151
+ f"todo_write: {in_progress} tasks are in_progress; keep at most "
152
+ "one — mark the rest pending or completed."
153
+ )
154
+ return normalized
155
+
156
+
157
+ def make_todo_write_tool(store: TodoStore) -> Any:
158
+ """Build the ``todo_write`` tool handler bound to ``store``.
159
+
160
+ The returned coroutine function carries the registration metadata the
161
+ router reads (name, description, schema) so a host registers it with one
162
+ call. Marked ``concurrency_safe=False``: two rewrites of the same chat's
163
+ list would race, and last-writer-wins is the wrong resolution for a
164
+ full-replace update.
165
+ """
166
+
167
+ async def todo_write(
168
+ todos: list[dict[str, Any]], context: dict[str, Any] | None = None
169
+ ) -> dict[str, Any]:
170
+ normalized = _normalize_todos(todos)
171
+ chat_id = str((context or {}).get("chat_id") or "")
172
+ store.set(chat_id, normalized)
173
+ counts = {status: 0 for status in TODO_STATUSES}
174
+ for item in normalized:
175
+ counts[item["status"]] += 1
176
+ return {
177
+ "todos": normalized,
178
+ "summary": {
179
+ "total": len(normalized),
180
+ "pending": counts["pending"],
181
+ "inProgress": counts["in_progress"],
182
+ "completed": counts["completed"],
183
+ },
184
+ }
185
+
186
+ todo_write.__steerable_tool_meta__ = { # type: ignore[attr-defined]
187
+ "name": TODO_TOOL_NAME,
188
+ "mode": "read", # session state only; no workspace side effects
189
+ "description": _DESCRIPTION,
190
+ "schema": TODO_SCHEMA,
191
+ "require_consent": False,
192
+ "concurrency_safe": False,
193
+ "exposure": "direct",
194
+ }
195
+ return todo_write
196
+
197
+
198
+ def todo_write_tool_descriptor() -> dict[str, Any]:
199
+ """OpenAI tool schema to append to the model's tools list.
200
+
201
+ The sidecar registers ``todo_write`` on its router (so dispatch works on
202
+ both the host path and the sidecar-local path) but the model only sees it
203
+ when the descriptor is appended to the ``tools`` array — mirroring how
204
+ run_code advertises itself.
205
+ """
206
+ return {
207
+ "type": "function",
208
+ "function": {
209
+ "name": TODO_TOOL_NAME,
210
+ "description": _DESCRIPTION,
211
+ "parameters": TODO_SCHEMA,
212
+ },
213
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.6.3
3
+ Version: 0.6.4
4
4
  Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -41,6 +41,7 @@ src/steerable_agent_runtime/sandboxed.py
41
41
  src/steerable_agent_runtime/skills.py
42
42
  src/steerable_agent_runtime/spill.py
43
43
  src/steerable_agent_runtime/subagent.py
44
+ src/steerable_agent_runtime/todo.py
44
45
  src/steerable_agent_runtime/tokens.py
45
46
  src/steerable_agent_runtime/tool_schema.py
46
47
  src/steerable_agent_runtime/tool_search.py
@@ -126,6 +127,7 @@ tests/test_storage_contract.py
126
127
  tests/test_stream_strip.py
127
128
  tests/test_subagent.py
128
129
  tests/test_system_proxy.py
130
+ tests/test_todo.py
129
131
  tests/test_tokens.py
130
132
  tests/test_tool_exposure.py
131
133
  tests/test_tool_hygiene.py
@@ -0,0 +1,156 @@
1
+ """The todo_write tool: full-replace semantics, validation, chat scoping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from steerable_agent_protocol.generated import ToolCall
7
+
8
+ from steerable_agent_runtime import (
9
+ TODO_TOOL_NAME,
10
+ TodoStore,
11
+ ToolRouter,
12
+ make_todo_write_tool,
13
+ )
14
+ from steerable_agent_runtime.errors import ToolDispatchError
15
+
16
+
17
+ def _make() -> tuple[TodoStore, ToolRouter]:
18
+ store = TodoStore()
19
+ router = ToolRouter()
20
+ tool = make_todo_write_tool(store)
21
+ meta = tool.__steerable_tool_meta__
22
+ router.register(
23
+ tool,
24
+ name=meta["name"],
25
+ mode=meta["mode"],
26
+ description=meta["description"],
27
+ schema=meta["schema"],
28
+ require_consent=meta["require_consent"],
29
+ concurrency_safe=meta["concurrency_safe"],
30
+ exposure=meta["exposure"],
31
+ )
32
+ return store, router
33
+
34
+
35
+ def _call(todos: object) -> ToolCall:
36
+ return ToolCall(id="c1", name=TODO_TOOL_NAME, arguments={"todos": todos})
37
+
38
+
39
+ def _todos(*specs: tuple[str, str]) -> list[dict[str, str]]:
40
+ return [
41
+ {"id": f"t{i}", "content": content, "status": status}
42
+ for i, (content, status) in enumerate(specs)
43
+ ]
44
+
45
+
46
+ async def test_full_replace_returns_list_and_summary() -> None:
47
+ store, router = _make()
48
+ result = await router.dispatch(
49
+ _call(_todos(("read code", "completed"), ("patch it", "in_progress"))),
50
+ context={"chat_id": "chat-1"},
51
+ )
52
+ assert result.success, result.error
53
+ data = result.data["value"]
54
+ assert [t["id"] for t in data["todos"]] == ["t0", "t1"]
55
+ assert data["summary"] == {
56
+ "total": 2,
57
+ "pending": 0,
58
+ "inProgress": 1,
59
+ "completed": 1,
60
+ }
61
+ # The store holds the new list for the chat.
62
+ assert [t["content"] for t in store.get("chat-1")] == ["read code", "patch it"]
63
+
64
+
65
+ async def test_second_call_replaces_the_first() -> None:
66
+ store, router = _make()
67
+ await router.dispatch(
68
+ _call(_todos(("old task", "pending"))), context={"chat_id": "chat-1"}
69
+ )
70
+ result = await router.dispatch(
71
+ _call(_todos(("new task", "in_progress"))), context={"chat_id": "chat-1"}
72
+ )
73
+ assert result.success, result.error
74
+ assert [t["content"] for t in store.get("chat-1")] == ["new task"]
75
+
76
+
77
+ async def test_chats_are_isolated() -> None:
78
+ store, router = _make()
79
+ await router.dispatch(
80
+ _call(_todos(("chat one task", "pending"))), context={"chat_id": "chat-1"}
81
+ )
82
+ await router.dispatch(
83
+ _call(_todos(("chat two task", "in_progress"))),
84
+ context={"chat_id": "chat-2"},
85
+ )
86
+ assert [t["content"] for t in store.get("chat-1")] == ["chat one task"]
87
+ assert [t["content"] for t in store.get("chat-2")] == ["chat two task"]
88
+
89
+
90
+ async def test_missing_context_uses_the_default_bucket() -> None:
91
+ store, router = _make()
92
+ result = await router.dispatch(_call(_todos(("orphan", "pending"))))
93
+ assert result.success, result.error
94
+ assert [t["content"] for t in store.get("")] == ["orphan"]
95
+
96
+
97
+ @pytest.mark.parametrize(
98
+ ("todos", "fragment"),
99
+ [
100
+ ("not-a-list", "must be an array"),
101
+ ([], "must contain 1-50 items"),
102
+ (["not-an-object"], "must be an object"),
103
+ ([{"content": "x", "status": "pending"}], 'non-empty string "id"'),
104
+ (
105
+ [
106
+ {"id": "a", "content": "one", "status": "pending"},
107
+ {"id": "a", "content": "two", "status": "pending"},
108
+ ],
109
+ 'duplicate id "a"',
110
+ ),
111
+ ([{"id": "a", "content": " ", "status": "pending"}], '"content"'),
112
+ (
113
+ [{"id": "a", "content": "x", "status": "doing"}],
114
+ "invalid status",
115
+ ),
116
+ (
117
+ [
118
+ {"id": "a", "content": "one", "status": "in_progress"},
119
+ {"id": "b", "content": "two", "status": "in_progress"},
120
+ ],
121
+ "at most one",
122
+ ),
123
+ ],
124
+ )
125
+ async def test_invalid_lists_are_rejected_without_touching_state(
126
+ todos: object, fragment: str
127
+ ) -> None:
128
+ store, router = _make()
129
+ await router.dispatch(
130
+ _call(_todos(("kept", "pending"))), context={"chat_id": "chat-1"}
131
+ )
132
+ result = await router.dispatch(_call(todos), context={"chat_id": "chat-1"})
133
+ assert not result.success
134
+ assert fragment in (result.error or "")
135
+ # A rejected rewrite leaves the previous list intact.
136
+ assert [t["content"] for t in store.get("chat-1")] == ["kept"]
137
+
138
+
139
+ def test_normalize_rejects_overlong_lists() -> None:
140
+ todos = [
141
+ {"id": f"t{i}", "content": f"task {i}", "status": "pending"}
142
+ for i in range(51)
143
+ ]
144
+ with pytest.raises(ToolDispatchError, match="1-50"):
145
+ from steerable_agent_runtime.todo import _normalize_todos
146
+
147
+ _normalize_todos(todos)
148
+
149
+
150
+ def test_meta_advertises_direct_exposure() -> None:
151
+ tool = make_todo_write_tool(TodoStore())
152
+ meta = tool.__steerable_tool_meta__
153
+ assert meta["name"] == "todo_write"
154
+ assert meta["exposure"] == "direct"
155
+ assert meta["require_consent"] is False
156
+ assert meta["concurrency_safe"] is False