tsugite-cli 0.24.1__tar.gz → 0.25.0__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 (183) hide show
  1. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/.gitignore +1 -0
  2. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/PKG-INFO +2 -1
  3. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/pyproject.toml +2 -1
  4. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_preparation.py +6 -5
  5. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/__init__.py +0 -2
  6. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/runner.py +22 -61
  7. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/approval.py +14 -7
  8. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_agents/default.md +3 -10
  9. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_agents/job_verifier.md +3 -1
  10. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/codebase-exploration/SKILL.md +1 -1
  11. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/response-patterns/SKILL.md +11 -14
  12. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/helpers.py +2 -2
  13. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/run.py +1 -52
  14. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/agent.py +86 -2
  15. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/base.py +2 -2
  16. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/models.py +22 -9
  17. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/sqlite_backend.py +15 -7
  18. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/ui_events.py +1 -0
  19. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/permissions.py +34 -5
  20. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/__init__.py +6 -0
  21. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/agents.py +2 -17
  22. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/http.py +23 -11
  23. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/jobs.py +5 -4
  24. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/notify.py +21 -6
  25. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/sessions.py +216 -6
  26. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/shell_tools.py +6 -3
  27. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/workspace/models.py +22 -6
  28. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/AGENTS.md +0 -0
  29. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/CONTRIBUTING.md +0 -0
  30. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/LICENSE +0 -0
  31. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/README.md +0 -0
  32. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/__init__.py +0 -0
  33. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_inheritance.py +0 -0
  34. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/exec_directives.py +0 -0
  35. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/exec_runner.py +0 -0
  36. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/helpers.py +0 -0
  37. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/history_integration.py +0 -0
  38. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/metrics.py +0 -0
  39. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/models.py +0 -0
  40. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/steps.py +0 -0
  41. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_runner/validation.py +0 -0
  42. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/agent_utils.py +0 -0
  43. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/__init__.py +0 -0
  44. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/agent_config.py +0 -0
  45. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/auto_context.py +0 -0
  46. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/base.py +0 -0
  47. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/delegation.py +0 -0
  48. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/file.py +0 -0
  49. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/inline.py +0 -0
  50. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/storage.py +0 -0
  51. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/attachments/url.py +0 -0
  52. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_agents/.gitkeep +0 -0
  53. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_agents/code_searcher.md +0 -0
  54. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_agents/file_searcher.md +0 -0
  55. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_agents/job_worker.md +0 -0
  56. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_agents/onboard.md +0 -0
  57. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_context.py +0 -0
  58. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/.gitkeep +0 -0
  59. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  60. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  61. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  62. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  63. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  64. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  65. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  66. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cache.py +0 -0
  67. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cancellation.py +0 -0
  68. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/__init__.py +0 -0
  69. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/agents.py +0 -0
  70. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/attachments.py +0 -0
  71. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/cache.py +0 -0
  72. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/chat.py +0 -0
  73. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/config.py +0 -0
  74. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/daemon.py +0 -0
  75. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/exec.py +0 -0
  76. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/history.py +0 -0
  77. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/init.py +0 -0
  78. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/models.py +0 -0
  79. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/plugins.py +0 -0
  80. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/render.py +0 -0
  81. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/secrets.py +0 -0
  82. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/skills.py +0 -0
  83. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/tools.py +0 -0
  84. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/usage.py +0 -0
  85. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/validate.py +0 -0
  86. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/cli/workspace.py +0 -0
  87. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/config.py +0 -0
  88. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/console.py +0 -0
  89. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/constants.py +0 -0
  90. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/context.py +0 -0
  91. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/context_block.py +0 -0
  92. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/__init__.py +0 -0
  93. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/content_blocks.py +0 -0
  94. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/executor.py +0 -0
  95. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/executor_registry.py +0 -0
  96. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/memory.py +0 -0
  97. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/proxy.py +0 -0
  98. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/record_store.py +0 -0
  99. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/sandbox.py +0 -0
  100. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/state.py +0 -0
  101. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/subprocess_executor.py +0 -0
  102. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/core/tools.py +0 -0
  103. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/daemon_client.py +0 -0
  104. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/events/__init__.py +0 -0
  105. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/events/base.py +0 -0
  106. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/events/bus.py +0 -0
  107. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/events/events.py +0 -0
  108. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/events/helpers.py +0 -0
  109. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/exceptions.py +0 -0
  110. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/__init__.py +0 -0
  111. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/reconstruction.py +0 -0
  112. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/registry.py +0 -0
  113. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/sqlite_conn.py +0 -0
  114. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/history/sqlite_schema.py +0 -0
  115. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/hooks.py +0 -0
  116. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/interaction.py +0 -0
  117. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/md_agents.py +0 -0
  118. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/models.py +0 -0
  119. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/options.py +0 -0
  120. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/plugins.py +0 -0
  121. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/prompt_xml.py +0 -0
  122. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/__init__.py +0 -0
  123. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/anthropic.py +0 -0
  124. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/base.py +0 -0
  125. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/model_cache.py +0 -0
  126. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/model_registry.py +0 -0
  127. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/ollama.py +0 -0
  128. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/openai_compat.py +0 -0
  129. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/providers/openrouter.py +0 -0
  130. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/py.typed +0 -0
  131. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/renderer.py +0 -0
  132. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/schemas/__init__.py +0 -0
  133. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/schemas/agent.schema.json +0 -0
  134. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/__init__.py +0 -0
  135. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/backend.py +0 -0
  136. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/env.py +0 -0
  137. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/exec.py +0 -0
  138. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/file.py +0 -0
  139. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/masking.py +0 -0
  140. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/redaction.py +0 -0
  141. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/registry.py +0 -0
  142. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/secrets/sqlite.py +0 -0
  143. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/shell_tool_config.py +0 -0
  144. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/skill_discovery.py +0 -0
  145. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/templates/AGENTS.md +0 -0
  146. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/templates/IDENTITY.md +0 -0
  147. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/templates/MEMORY.md +0 -0
  148. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/templates/USER.md +0 -0
  149. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/templates/personas/casual-technical.md +0 -0
  150. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/templates/personas/marvin.md +0 -0
  151. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/templates/personas/minimal.md +0 -0
  152. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/artifacts.py +0 -0
  153. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/daemon_control.py +0 -0
  154. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/fs.py +0 -0
  155. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/history.py +0 -0
  156. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/interactive.py +0 -0
  157. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/schedule.py +0 -0
  158. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/secrets.py +0 -0
  159. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/shell.py +0 -0
  160. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/skills.py +0 -0
  161. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tools/time.py +0 -0
  162. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/tsugite.py +0 -0
  163. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/__init__.py +0 -0
  164. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/base.py +0 -0
  165. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/chat.py +0 -0
  166. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/dispatch.py +0 -0
  167. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/helpers.py +0 -0
  168. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/jsonl.py +0 -0
  169. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/plain.py +0 -0
  170. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/repl_chat.py +0 -0
  171. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/repl_commands.py +0 -0
  172. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/repl_completer.py +0 -0
  173. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui/repl_handler.py +0 -0
  174. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui_context.py +0 -0
  175. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/ui_surfaces.py +0 -0
  176. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/usage/__init__.py +0 -0
  177. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/usage/quota.py +0 -0
  178. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/usage/store.py +0 -0
  179. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/user_agent.py +0 -0
  180. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/utils.py +0 -0
  181. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/workspace/__init__.py +0 -0
  182. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/workspace/session.py +0 -0
  183. {tsugite_cli-0.24.1 → tsugite_cli-0.25.0}/tsugite/workspace/templates.py +0 -0
@@ -196,3 +196,4 @@ plugins/tsugite-daemon/tsugite_daemon/web/
196
196
  !plugins/tsugite-daemon/frontend/src/lib/**
197
197
  # Per-job git worktrees provisioned by the daemon.
198
198
  .tsugite-jobs/
199
+ /packaging/runtime/.cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.24.1
3
+ Version: 0.25.0
4
4
  Summary: Micro-agent runner for task automation using markdown definitions
5
5
  Author: Justyn Shull
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -241,6 +241,7 @@ License: GNU AFFERO GENERAL PUBLIC LICENSE
241
241
  License-File: LICENSE
242
242
  Keywords: agents,automation,cli,jinja2
243
243
  Requires-Python: >=3.11
244
+ Requires-Dist: certifi>=2026.10.23
244
245
  Requires-Dist: cryptography>=46.0.5
245
246
  Requires-Dist: html2text>=2020.1.16
246
247
  Requires-Dist: httpx>=0.25
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tsugite-cli"
3
- version = "0.24.1"
3
+ version = "0.25.0"
4
4
  description = "Micro-agent runner for task automation using markdown definitions"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "jinja2>=3.1",
13
13
  "pyyaml>=6.0",
14
14
  "httpx>=0.25",
15
+ "certifi>=2026.10.23",
15
16
  "rich>=13.0",
16
17
  "questionary>=2.1.1",
17
18
  "html2text>=2020.1.16",
@@ -434,7 +434,6 @@ class AgentPreparer:
434
434
  _combine_instructions,
435
435
  execute_exec_directives,
436
436
  execute_tool_directives,
437
- get_default_instructions,
438
437
  )
439
438
  from tsugite.core.agent import build_system_prompt
440
439
  from tsugite.renderer import AgentRenderer
@@ -506,12 +505,16 @@ class AgentPreparer:
506
505
  skip_reason=skip_reason,
507
506
  )
508
507
 
508
+ tools = self._expand_tools(agent_config, full_context, interactive_mode)
509
+ # Templates test membership against the names actually installed, not the
510
+ # frontmatter specs (`"@secrets"`).
511
+ full_context["tools"] = [t.name for t in tools]
512
+
509
513
  try:
510
514
  rendered_prompt = renderer.render(modified_content, full_context)
511
515
  except Exception as e:
512
516
  raise RuntimeError(f"Template rendering failed: {e}") from e
513
517
 
514
- base_instructions = get_default_instructions()
515
518
  agent_instructions = getattr(agent_config, "instructions", "")
516
519
 
517
520
  # Render agent instructions as Jinja2 template
@@ -521,9 +524,7 @@ class AgentPreparer:
521
524
  except Exception as e:
522
525
  raise RuntimeError(f"Failed to render agent instructions: {e}") from e
523
526
 
524
- combined_instructions = _combine_instructions(base_instructions, agent_instructions)
525
-
526
- tools = self._expand_tools(agent_config, full_context, interactive_mode)
527
+ combined_instructions = _combine_instructions(agent_instructions)
527
528
 
528
529
  skill_load = self._load_skills(agent_config, prompt, full_context, _skill_manager, event_bus)
529
530
  skills, expiring_skills = skill_load.skills, skill_load.expiring
@@ -31,7 +31,6 @@ from tsugite.agent_runner.runner import ( # noqa: F401
31
31
  _execute_agent_with_prompt,
32
32
  execute_prefetch,
33
33
  execute_tool_directives,
34
- get_default_instructions,
35
34
  preview_multistep_agent,
36
35
  run_agent,
37
36
  run_agent_async,
@@ -47,7 +46,6 @@ __all__ = [
47
46
  "execute_exec_directives",
48
47
  "run_python_block",
49
48
  "ExecBlockResult",
50
- "get_default_instructions",
51
49
  "AgentExecutionResult",
52
50
  "StepMetrics",
53
51
  "display_step_metrics",
@@ -155,31 +155,6 @@ def _setup_event_context(event_bus: Optional["EventBus"]) -> None:
155
155
  set_ui_context(event_bus=event_bus)
156
156
 
157
157
 
158
- def get_default_instructions() -> str:
159
- """Get minimal default instructions. Detailed guidance comes from skills.
160
-
161
- Returns:
162
- Default instructions for code execution mode
163
- """
164
- base = "You accomplish tasks by writing Python code.\n\n"
165
-
166
- output = (
167
- "## Output\n\n"
168
- "- `print(x)` - See in next turn (internal)\n"
169
- "- `send_message(msg)` - Show user progress\n"
170
- "- `final_answer(msg)` - Final response (stops execution)\n\n"
171
- )
172
-
173
- rules = (
174
- "## Rules\n\n"
175
- "1. Always respond with Python code blocks\n"
176
- "2. Call `final_answer()` when done\n"
177
- "3. Variables persist between turns\n"
178
- )
179
-
180
- return base + output + rules
181
-
182
-
183
158
  def _render_args(value: Any, renderer: AgentRenderer, context: Dict[str, Any]) -> Any:
184
159
  """Recursively render Jinja in string leaves of a tool args structure.
185
160
 
@@ -320,32 +295,17 @@ def execute_tool_directives(
320
295
  return modified_content, new_context
321
296
 
322
297
 
323
- def _extract_reasoning_content(agent: TsugiteAgent, custom_logger: Optional[Any] = None) -> None:
324
- """Extract and display reasoning content from TsugiteAgent memory.
325
-
326
- For models like Claude/Deepseek that expose reasoning_content, displays the actual reasoning.
327
-
328
- Args:
329
- agent: The TsugiteAgent instance that just completed execution
330
- custom_logger: Custom logger to display reasoning content
331
- """
332
- if not hasattr(agent, "memory") or not agent.memory.reasoning_history:
333
- return
298
+ # Daemon chat sources with a person at the other end; any other source counts as background.
299
+ INTERACTIVE_SOURCES = frozenset({"http", "discord"})
334
300
 
335
- # Display each reasoning entry
336
- for reasoning_content in agent.memory.reasoning_history:
337
- if reasoning_content and custom_logger:
338
- # Check if custom_logger has ui_handler (custom UI mode)
339
- ui_handler = get_ui_handler(custom_logger)
340
- if ui_handler:
341
- from tsugite.events import EventBus, ReasoningContentEvent
342
301
 
343
- event_bus = EventBus()
344
- event_bus.subscribe(ui_handler.handle_event)
345
- event_bus.emit(ReasoningContentEvent(content=reasoning_content, step=None))
302
+ def _run_is_interactive(channel_metadata: Optional[Dict[str, Any]]) -> bool:
303
+ """Whether a person is present: the session source in the daemon, the TTY otherwise."""
304
+ source = (channel_metadata or {}).get("source")
305
+ return source in INTERACTIVE_SOURCES if source else is_interactive()
346
306
 
347
307
 
348
- def _make_pre_llm_call_callback(hooks_dir: Path, agent_name: str):
308
+ def _make_pre_llm_call_callback(hooks_dir: Path, agent_name: str, interactive: bool):
349
309
  """Build an async callback that fires pre_llm_call hooks to mutate outgoing
350
310
  messages in place. Returns None when no such hooks exist (keeps the hot path clean)."""
351
311
  from tsugite.hooks import fire_hooks, load_hooks_config
@@ -359,7 +319,7 @@ def _make_pre_llm_call_callback(hooks_dir: Path, agent_name: str):
359
319
  hooks_dir,
360
320
  "pre_llm_call",
361
321
  {"messages": messages, "model": model, "agent": agent_name},
362
- interactive=is_interactive(),
322
+ interactive=interactive,
363
323
  )
364
324
 
365
325
  return _callback
@@ -449,8 +409,9 @@ async def _execute_agent_with_prompt(
449
409
  hooks_dir = path_context.effective_cwd
450
410
  else:
451
411
  hooks_dir = Path.cwd()
452
- setup_hook_handler(hooks_dir, event_bus, interactive=is_interactive())
453
- pre_llm_call_cb = _make_pre_llm_call_callback(hooks_dir, agent_config.name)
412
+ interactive = _run_is_interactive(channel_metadata)
413
+ setup_hook_handler(hooks_dir, event_bus, interactive=interactive)
414
+ pre_llm_call_cb = _make_pre_llm_call_callback(hooks_dir, agent_config.name, interactive)
454
415
 
455
416
  # Start with tools from prepared agent
456
417
  tools = list(prepared.tools) # Make a copy
@@ -637,9 +598,6 @@ async def _execute_agent_with_prompt(
637
598
  stream=exec_options.stream,
638
599
  )
639
600
 
640
- # Extract and display reasoning content if present
641
- _extract_reasoning_content(agent, custom_logger)
642
-
643
601
  if usage_sink is not None:
644
602
  usage_sink.update(
645
603
  token_count=agent.total_tokens,
@@ -839,15 +797,17 @@ def _domain_within_ceiling(desired_pattern: str, ceiling: list) -> bool:
839
797
 
840
798
 
841
799
  def _resolve_workspace_dir(workspace: Optional[Any], path_context: Optional[Any]) -> Optional[Path]:
842
- """Resolve the executor's workspace directory.
800
+ """Resolve the directory the executor runs code in.
843
801
 
844
- Prefer an explicit Workspace object (CLI workspace runs); otherwise fall back
845
- to the PathContext's workspace_dir. The daemon passes only a path_context (no
846
- Workspace), so without this fallback the sandbox would bind/chdir the daemon's
847
- CWD instead of the agent's workspace (or job worktree).
802
+ Checks: an explicit Workspace -> the directory bound with set_workspace_dir
803
+ (the daemon binds the session's working directory) -> path_context.workspace_dir.
848
804
  """
805
+ from tsugite.cli.helpers import get_workspace_dir
806
+
849
807
  if workspace:
850
808
  return workspace.path
809
+ if bound := get_workspace_dir():
810
+ return bound
851
811
  if path_context and getattr(path_context, "workspace_dir", None):
852
812
  return path_context.workspace_dir
853
813
  return None
@@ -934,7 +894,7 @@ async def _run_unit(
934
894
  "rendered_prompt": prepared.rendered_prompt[:500] if prepared.rendered_prompt else "",
935
895
  "tools": [t.name for t in prepared.tools] if prepared.tools else [],
936
896
  },
937
- interactive=is_interactive(),
897
+ interactive=_run_is_interactive(setup.channel_metadata),
938
898
  on_status=setup.on_status,
939
899
  on_result=setup.on_hook_result,
940
900
  )
@@ -1140,10 +1100,11 @@ async def run_agent_async(
1140
1100
  on_status = None
1141
1101
  on_hook_result = None
1142
1102
 
1103
+ interactive = _run_is_interactive(channel_metadata)
1143
1104
  hook_vars = await fire_pre_message_hooks(
1144
1105
  hooks_dir,
1145
1106
  {"message": hook_message, "agent_name": agent_path.stem},
1146
- interactive=is_interactive(),
1107
+ interactive=interactive,
1147
1108
  on_status=on_status,
1148
1109
  on_result=on_hook_result,
1149
1110
  )
@@ -1157,7 +1118,7 @@ async def run_agent_async(
1157
1118
  hooks_dir,
1158
1119
  "pre_context_build",
1159
1120
  {"message": hook_message, "agent_name": agent_path.stem, **context, "blocks": context_blocks},
1160
- interactive=is_interactive(),
1121
+ interactive=interactive,
1161
1122
  on_status=on_status,
1162
1123
  on_result=on_hook_result,
1163
1124
  )
@@ -31,23 +31,28 @@ _LABEL_TO_DECISION = {
31
31
 
32
32
 
33
33
  def request_approval(
34
- prompt: str, *, allow_always: bool = False, allow_never: bool = False, detail: Optional[str] = None
34
+ prompt: str,
35
+ *,
36
+ allow_always: bool = False,
37
+ allow_never: bool = False,
38
+ extra_options: Optional[dict[str, str]] = None,
39
+ detail: Optional[str] = None,
35
40
  ) -> str:
36
41
  """Ask the user to approve an action, returning an :data:`ApprovalDecision`.
37
42
 
38
- Builds ``Approve``/``Deny`` options (plus ``Always allow`` when ``allow_always``
39
- and ``Always deny`` when ``allow_never``) and routes them through the active
40
- interaction backend as an ``"approval"`` question, then maps the chosen label
41
- back to a decision.
43
+ Offers ``Approve``/``Deny`` plus the optional choices below as an
44
+ ``"approval"`` question on the active interaction backend, and maps the
45
+ chosen label back to a decision.
42
46
 
43
47
  Args:
44
48
  prompt: The action to approve, phrased as a question.
45
49
  allow_always: Offer an "Always allow" option (a persistent allow).
46
50
  allow_never: Offer an "Always deny" option (a persistent deny).
51
+ extra_options: Caller-specific choices, as ``{label: decision}``.
47
52
  detail: Extra context appended below the prompt for the user to read.
48
53
 
49
54
  Returns:
50
- One of ``"approve"``, ``"deny"``, ``"always"``, or ``"never"``.
55
+ One of ``"approve"``, ``"deny"``, ``"always"``, ``"never"``, or a value of ``extra_options``.
51
56
 
52
57
  Fail-closed: returns ``"deny"`` WITHOUT prompting when there is no backend or
53
58
  the backend is non-interactive, and for any unrecognized reply.
@@ -61,7 +66,9 @@ def request_approval(
61
66
  options.append(ALWAYS_LABEL)
62
67
  if allow_never:
63
68
  options.append(NEVER_LABEL)
69
+ decisions = _LABEL_TO_DECISION | (extra_options or {})
70
+ options.extend(extra_options or ())
64
71
 
65
72
  question = f"{prompt}\n\n{detail}" if detail else prompt
66
73
  answer = backend.ask_user(question, "approval", options)
67
- return _LABEL_TO_DECISION.get(answer, "deny")
74
+ return decisions.get(answer, "deny")
@@ -73,7 +73,7 @@ instructions: |
73
73
  - Break down complex tasks into clear steps
74
74
  - Ask clarifying questions when the task is ambiguous
75
75
  - Write Python code to accomplish tasks
76
- - Call return_value(result) when you've completed the task
76
+ - When you've completed the task, reply in plain text, or call return_value() for structured output
77
77
  - If you intend to call multiple tools and there are no dependencies between the calls, make all independent calls in a single code block
78
78
  - Never guess about code you have not read. Use read_file or list_files to investigate before answering questions about code
79
79
 
@@ -235,13 +235,6 @@ You have running tmux sessions that you can interact with using tmux tools (tmux
235
235
  {% endfor %}
236
236
  {% endif %}
237
237
 
238
- {% if "run" in tools and "tmux_create" in tools %}
239
- ## Shell vs Tmux
240
-
241
- - Use `run()` for commands that produce output and exit: `run("ls -la")`, `run("git status")`, `run("python script.py")`
242
- - Use tmux tools for interactive programs (htop, k9s, psql, python REPL), long-running processes you want to monitor, or when you need a persistent shell session where state carries across commands
243
- {% endif %}
244
-
245
238
  {% if subagent_instructions is defined and subagent_instructions %}
246
239
  {{ subagent_instructions }}
247
240
  {% endif %}
@@ -299,11 +292,11 @@ Load these skills for specialized knowledge:
299
292
  - **{{ skill.name }}** - {{ skill.description }}
300
293
  {% endfor %}
301
294
 
302
- Call `load_skill("skill_name")` and stop. The skill content appears next turn as `<loaded_skill>`. Then use what you learned.
295
+ Call `load_skill("skill_name")` and stop. The skill content appears next turn as `<skill_content>`. Then use what you learned.
303
296
 
304
297
  Do not act in the same turn you load a skill. Skip loading if you already know how to do the task.
305
298
 
306
- Skills may show bash commands. Translate to Python: `shell.run("kubectl get pods")`
299
+ Skills may show bash commands. Translate to Python: `run("kubectl get pods")`
307
300
  </available_skills>
308
301
  {% endif %}
309
302
 
@@ -76,7 +76,9 @@ is `true` only if every `ac_results[i].pass` is `true`.
76
76
 
77
77
  `reason` opens with the basis for the verdict, then one sentence:
78
78
 
79
- - `inspected:` - you read the artifact, file, or Output text yourself.
79
+ - `inspected:` - you read the artifact or file yourself with a tool call.
80
+ - `read Output:` - the deliverable is inline text and you judged the `## Output`
81
+ text itself, with no tool call.
80
82
  - `ran <command>:` - you executed the command and judged its exit status.
81
83
  - `accepted worker claim:` - you could not check it, so the verdict rests on
82
84
  the worker's report.
@@ -365,7 +365,7 @@ If available via custom tools configuration, consider using:
365
365
  tools:
366
366
  - name: ast_search
367
367
  description: Search code using AST patterns
368
- command: "ast-grep -p '{pattern}' {path}"
368
+ command: "ast-grep -p {pattern} {path}"
369
369
  parameters:
370
370
  pattern: {required: true}
371
371
  path: "."
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: response-patterns
3
- description: How to respond effectively - when to use print/send_message/final_answer/ask_user
3
+ description: How to respond effectively - when to use print/send_message/return_value/ask_user
4
4
  ---
5
5
 
6
6
  # Response Patterns
@@ -11,21 +11,18 @@ description: How to respond effectively - when to use print/send_message/final_a
11
11
  |----------|----------|-----------|---------|
12
12
  | `print(x)` | You (next turn) | Continues | Intermediate data |
13
13
  | `send_message(msg)` | User | Continues | Progress updates |
14
- | `final_answer(msg)` | User | **Stops** | Final response |
14
+ | `return_value(value)` | User | **Stops** | Structured final result |
15
15
  | `ask_user(q)` | User | **Blocks** | Get user input |
16
16
 
17
17
  ## Simple Responses
18
18
 
19
- No functions needed? One line:
19
+ No functions needed? Reply in plain text:
20
20
 
21
- ```python-exec
22
- final_answer("Hello! What can I help you with?")
23
- ```
21
+ - "Thanks" replies `You're welcome!`
22
+ - "What's 2+2?" replies `4`
23
+ - "What can you do?" replies `I can read/write files, run commands, and help with tasks.`
24
24
 
25
- More examples:
26
- - "Thanks" → `final_answer("You're welcome!")`
27
- - "What's 2+2?" → `final_answer("4")`
28
- - "What can you do?" → `final_answer("I can read/write files, run commands, search code, and help with tasks.")`
25
+ Call `return_value(value)` only when the caller needs a structured result.
29
26
 
30
27
  ## Progress Updates
31
28
 
@@ -33,14 +30,14 @@ For longer tasks, keep user informed:
33
30
 
34
31
  ```python-exec
35
32
  send_message("Searching codebase...")
36
- results = search_code("def main")
33
+ results = run("grep -rn 'def main' .")
37
34
  print(results) # You'll see this next turn
38
35
  ```
39
36
 
40
37
  ```python-exec
41
- send_message(f"Found {len(results)} matches. Analyzing...")
38
+ send_message(f"Found {len(results.splitlines())} matches. Analyzing...")
42
39
  # ... more work ...
43
- final_answer("Here's what I found: ...")
40
+ return_value("Here's what I found: ...")
44
41
  ```
45
42
 
46
43
  ## Asking the User
@@ -87,7 +84,7 @@ Use `ask_user` when:
87
84
  - Ask unnecessary questions when a reasonable default exists
88
85
 
89
86
  ✅ Do:
90
- - `final_answer()` immediately for simple responses
87
+ - Plain text immediately for simple responses
91
88
  - `send_message()` for progress on tasks > 5 seconds
92
89
  - `ask_user()` when you genuinely need user input
93
90
  - `print()` only for data YOU need to see
@@ -500,8 +500,8 @@ class PathContext:
500
500
  effective_cwd: Path
501
501
 
502
502
 
503
- # Mirrors PathContext.workspace_dir for call sites (fs tools, shell, subprocesses)
504
- # that don't receive PathContext explicitly. Task-local so concurrent agent runs
503
+ # The directory the running agent's code and fs tools work in, for call sites
504
+ # (fs tools, shell, subprocesses, the executor) that don't receive PathContext explicitly. Task-local so concurrent agent runs
505
505
  # don't share state.
506
506
  _workspace_dir_cv: contextvars.ContextVar[Optional[Path]] = contextvars.ContextVar("workspace_dir", default=None)
507
507
 
@@ -257,7 +257,6 @@ def run(
257
257
  ),
258
258
  no_workspace: bool = typer.Option(False, "--no-workspace", help="Disable workspace (ignore default workspace)"),
259
259
  new_session: bool = typer.Option(False, "--new-session", help="Start fresh session (ignore workspace session)"),
260
- join_daemon: bool = typer.Option(False, "--daemon", "-d", help="Join the daemon's most recent session"),
261
260
  sandbox: bool = typer.Option(False, "--sandbox", help="Run agent code in bubblewrap sandbox"),
262
261
  no_sandbox: bool = typer.Option(False, "--no-sandbox", help="Disable sandbox (overrides config)"),
263
262
  allow_domain: Optional[List[str]] = typer.Option(
@@ -282,7 +281,6 @@ def run(
282
281
  tsu run --continue "prompt" # Continue latest conversation (auto-detect agent)
283
282
  tsu run +assistant --continue "prompt" # Continue latest with specific agent
284
283
  tsu run --continue --conversation-id CONV_ID "prompt" # Continue specific conversation
285
- tsu run --daemon "follow up message" # Join the daemon's most recent session
286
284
  """
287
285
  from tsugite.agent_runner import get_agent_info, run_agent
288
286
  from tsugite.console import get_stderr_console
@@ -381,7 +379,7 @@ def run(
381
379
  # Resolve UI mode to update ui_opts
382
380
  ui_opts = _resolve_ui_mode(ui, ui_opts, stderr_console)
383
381
 
384
- # Handle subagent mode and daemon mode (both need os)
382
+ # Handle subagent mode
385
383
  import os
386
384
 
387
385
  if subagent_mode:
@@ -393,54 +391,6 @@ def run(
393
391
  history_opts.enabled = False
394
392
  os.environ["TSUGITE_SUBAGENT_MODE"] = "1"
395
393
 
396
- daemon_metadata = None
397
- if join_daemon:
398
- from tsugite.agent_runner.history_integration import get_latest_conversation
399
-
400
- try:
401
- from tsugite_daemon.config import load_daemon_config
402
-
403
- # Probe: the daemon battery has to be installed and configured.
404
- load_daemon_config()
405
- except ModuleNotFoundError as e:
406
- stderr_console.print(
407
- "[red]The daemon battery is not installed.[/red] Install it with: pip install tsugite-cli[daemon]"
408
- )
409
- raise typer.Exit(1) from e
410
- except ValueError as e:
411
- stderr_console.print(f"[red]Daemon config not found: {e}[/red]")
412
- stderr_console.print("[dim]Run 'tsugite daemon' to start daemon first[/dim]")
413
- raise typer.Exit(1)
414
-
415
- user_id = os.environ.get("USER", "cli-user")
416
-
417
- latest_conv_id = get_latest_conversation()
418
- if latest_conv_id:
419
- stderr_console.print(f"[cyan]Joining daemon session: {latest_conv_id}[/cyan]")
420
- history_opts.continue_id = latest_conv_id
421
- else:
422
- stderr_console.print("[yellow]No active daemon session found[/yellow]")
423
- stderr_console.print("[dim]Creating new daemon-managed session...[/dim]")
424
-
425
- # Build CLI metadata for history
426
- from datetime import datetime, timezone
427
-
428
- daemon_metadata = {
429
- "source": "cli",
430
- "channel_id": None,
431
- "user_id": user_id,
432
- "reply_to": "cli",
433
- "is_daemon_managed": True,
434
- "timestamp": datetime.now(timezone.utc).isoformat(),
435
- }
436
-
437
- all_attachments = list(attachment) if attachment else []
438
- attach_opts = AttachmentOptions(
439
- sources=all_attachments,
440
- refresh_cache=refresh_cache,
441
- auto_context=auto_context,
442
- )
443
-
444
394
  # Handle conversation continuation - check before parsing args
445
395
  if continue_conversation and not history_opts.continue_id:
446
396
  from tsugite.agent_runner.history_integration import get_latest_conversation
@@ -641,7 +591,6 @@ def run(
641
591
  continue_conversation_id=target_session,
642
592
  system_prompt=system_prompt,
643
593
  attachments=attachments,
644
- channel_metadata=daemon_metadata,
645
594
  status=status,
646
595
  error_message=error_message,
647
596
  )
@@ -173,6 +173,40 @@ def _has_tool_call_markup(content: str) -> bool:
173
173
  return bool(content) and _TOOL_CALL_MARKUP.search(_CODE_MARKUP.sub("", content)) is not None
174
174
 
175
175
 
176
+ # A single response holding this many characters of one whitespace run, or of one
177
+ # short chunk repeated back to back, is a degenerate loop, not output. Real
178
+ # content (long reports, tables, code) never gets near it. Not a length cap.
179
+ _RUNAWAY_MIN_CHARS = 5000
180
+ _RUNAWAY_CHECK_STRIDE = 1000
181
+ _WHITESPACE_RUN = re.compile(rf"\s{{{_RUNAWAY_MIN_CHARS},}}")
182
+ _MAX_CHUNK = 256
183
+ _REPEATED_CHUNK = re.compile(rf"(.{{1,{_MAX_CHUNK}}}?)\1+", re.DOTALL)
184
+
185
+
186
+ class RunawayOutputError(Exception):
187
+ """A streamed response degenerated into a whitespace run or a repeated chunk."""
188
+
189
+ def __init__(self, reason: str, content: str):
190
+ super().__init__(reason)
191
+ self.reason = reason
192
+ self.content = content
193
+
194
+
195
+ def _find_runaway(content: str) -> Optional[tuple[str, int]]:
196
+ """Return (reason, start offset) of a degenerate run in the tail of `content`."""
197
+ window_start = max(0, len(content) - (_RUNAWAY_MIN_CHARS + _MAX_CHUNK + _RUNAWAY_CHECK_STRIDE))
198
+ window = content[window_start:]
199
+ if m := _WHITESPACE_RUN.search(window):
200
+ return f"a whitespace run of at least {_RUNAWAY_MIN_CHARS} characters", window_start + m.start()
201
+ tail = window[-_MAX_CHUNK:]
202
+ # Prefilter: skip the regex unless the tail text occurred earlier in the window.
203
+ if window.find(tail) < len(window) - len(tail):
204
+ for m in _REPEATED_CHUNK.finditer(window):
205
+ if len(m.group(0)) >= _RUNAWAY_MIN_CHARS:
206
+ return f"one chunk repeated for at least {_RUNAWAY_MIN_CHARS} characters", window_start + m.start()
207
+ return None
208
+
209
+
176
210
  # Tags the runtime injects into the model's NEXT user message after executing
177
211
  # its code (the execution result, multi-block warning, and budget hints). A
178
212
  # well-behaved model never writes these itself; one that does is impersonating
@@ -233,6 +267,15 @@ def _build_unexecuted_tool_call_notice_xml() -> str:
233
267
  )
234
268
 
235
269
 
270
+ def _build_runaway_notice_xml(reason: str) -> str:
271
+ return (
272
+ "\n<tsugite_runaway_output_notice>"
273
+ f"Your response was cut off because it contained {reason}. "
274
+ "Nothing in it ran. Reply again with prose or exactly one ```python-exec block."
275
+ "</tsugite_runaway_output_notice>"
276
+ )
277
+
278
+
236
279
  def _build_multi_block_warning_xml(count: int) -> str:
237
280
  """Model-visible note appended to a turn's observation when the agent
238
281
  received N>1 ```python blocks in one response.
@@ -480,7 +523,39 @@ class TsugiteAgent:
480
523
 
481
524
  self._record_prompt_snapshot(messages, turn_num)
482
525
 
483
- turn = await self._provider_turn(messages, turn_num, stream)
526
+ try:
527
+ turn = await self._provider_turn(messages, turn_num, stream)
528
+ except RunawayOutputError as e:
529
+ logger.warning("Aborted runaway response (step %d): %s", turn_num + 1, e.reason)
530
+ aborted_text, _ = escape_runtime_injection_tags(e.content)
531
+ last_response_text = aborted_text
532
+ self._previous_turn_had_error = True
533
+ if self.event_bus:
534
+ self.event_bus.emit(
535
+ WarningEvent(
536
+ message=f"Response aborted: it contained {e.reason}.",
537
+ category="runaway_output",
538
+ step=turn_num + 1,
539
+ )
540
+ )
541
+ self.memory.add_step(
542
+ thought="",
543
+ code="",
544
+ output="",
545
+ error=e.reason,
546
+ tools_called=[],
547
+ raw_content=aborted_text,
548
+ xml_observation=_build_runaway_notice_xml(e.reason),
549
+ )
550
+ self._record_model_response(
551
+ turn_num,
552
+ raw_content=aborted_text,
553
+ parsed=ParsedResponse(thought=aborted_text, code=""),
554
+ usage=None,
555
+ cost=None,
556
+ response=None,
557
+ )
558
+ continue
484
559
  thought, code = turn.thought, turn.code
485
560
  last_response_text = turn.response.content if turn.response else (thought or "")
486
561
 
@@ -855,11 +930,20 @@ class TsugiteAgent:
855
930
  chunks = await self._provider.acompletion(
856
931
  messages=messages, model=self._model_id, stream=True, **self._model_kwargs
857
932
  )
933
+ checked_len = 0
858
934
  async for chunk in chunks:
859
935
  if chunk.content:
860
936
  content += chunk.content
861
937
  if self.event_bus:
862
938
  self.event_bus.emit(StreamChunkEvent(chunk=chunk.content))
939
+ if len(content) - checked_len >= _RUNAWAY_CHECK_STRIDE:
940
+ checked_len = len(content)
941
+ if runaway := _find_runaway(content):
942
+ reason, start = runaway
943
+ await chunks.aclose()
944
+ if self.event_bus:
945
+ self.event_bus.emit(StreamCompleteEvent())
946
+ raise RunawayOutputError(reason, content[:start])
863
947
  if getattr(chunk, "reasoning_content", ""):
864
948
  reasoning += chunk.reasoning_content
865
949
  if self.event_bus:
@@ -1188,7 +1272,7 @@ class TsugiteAgent:
1188
1272
  {"role": "assistant", "content": "previous response 1"},
1189
1273
  {"role": "user", "content": task},
1190
1274
  {"role": "assistant", "content": "```python-exec\\n...```"},
1191
- {"role": "user", "content": <loaded_skill>...</loaded_skill>\\n<observation>..."},
1275
+ {"role": "user", "content": <skill_content>...</skill_content>\\n<observation>..."},
1192
1276
  ...
1193
1277
  ]
1194
1278
  """
@@ -18,9 +18,9 @@ class Session(Protocol):
18
18
 
19
19
  session_id: str
20
20
 
21
- def record(self, type: str, *, ts: Optional[datetime] = None, **data: Any) -> None: ...
21
+ def record(self, type: str, *, ts: Optional[datetime] = None, **data: Any) -> int: ...
22
22
 
23
- def record_many(self, events: Iterable[Event]) -> None: ...
23
+ def record_many(self, events: Iterable[Event]) -> List[int]: ...
24
24
 
25
25
  def iter_events(self, types: Optional[Iterable[str]] = None) -> Iterator[Event]: ...
26
26