tsugite-cli 0.22.1__tar.gz → 0.23.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 (182) hide show
  1. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/.gitignore +3 -1
  2. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/AGENTS.md +16 -9
  3. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/CONTRIBUTING.md +9 -16
  4. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/PKG-INFO +1 -1
  5. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/pyproject.toml +1 -1
  6. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_preparation.py +26 -30
  7. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/exec_runner.py +4 -3
  8. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/helpers.py +3 -7
  9. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/runner.py +6 -1
  10. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/steps.py +2 -2
  11. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/agent_config.py +12 -7
  12. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/auto_context.py +11 -20
  13. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/agent.py +4 -0
  14. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/subprocess_executor.py +11 -3
  15. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/exceptions.py +21 -1
  16. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/anthropic.py +1 -2
  17. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/openai_compat.py +5 -13
  18. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/__init__.py +9 -16
  19. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/agents.py +19 -38
  20. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/artifacts.py +19 -18
  21. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/fs.py +73 -103
  22. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/http.py +45 -53
  23. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/interactive.py +20 -47
  24. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/jobs.py +20 -26
  25. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/schedule.py +8 -7
  26. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/shell_tools.py +5 -14
  27. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/utils.py +3 -2
  28. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/LICENSE +0 -0
  29. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/README.md +0 -0
  30. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/__init__.py +0 -0
  31. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_inheritance.py +0 -0
  32. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/__init__.py +0 -0
  33. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/exec_directives.py +0 -0
  34. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/history_integration.py +0 -0
  35. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/metrics.py +0 -0
  36. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/models.py +0 -0
  37. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_runner/validation.py +0 -0
  38. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/agent_utils.py +0 -0
  39. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/approval.py +0 -0
  40. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/__init__.py +0 -0
  41. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/base.py +0 -0
  42. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/delegation.py +0 -0
  43. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/file.py +0 -0
  44. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/inline.py +0 -0
  45. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/storage.py +0 -0
  46. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/attachments/url.py +0 -0
  47. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_agents/.gitkeep +0 -0
  48. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_agents/code_searcher.md +0 -0
  49. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_agents/default.md +0 -0
  50. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_agents/file_searcher.md +0 -0
  51. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_agents/job_verifier.md +0 -0
  52. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_agents/job_worker.md +0 -0
  53. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_agents/onboard.md +0 -0
  54. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_context.py +0 -0
  55. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/.gitkeep +0 -0
  56. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  57. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  58. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  59. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  60. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  61. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  62. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  63. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  64. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  65. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cache.py +0 -0
  66. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cancellation.py +0 -0
  67. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/__init__.py +0 -0
  68. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/agents.py +0 -0
  69. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/attachments.py +0 -0
  70. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/cache.py +0 -0
  71. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/chat.py +0 -0
  72. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/config.py +0 -0
  73. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/daemon.py +0 -0
  74. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/exec.py +0 -0
  75. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/helpers.py +0 -0
  76. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/history.py +0 -0
  77. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/init.py +0 -0
  78. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/models.py +0 -0
  79. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/plugins.py +0 -0
  80. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/render.py +0 -0
  81. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/run.py +0 -0
  82. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/secrets.py +0 -0
  83. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/skills.py +0 -0
  84. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/tools.py +0 -0
  85. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/usage.py +0 -0
  86. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/validate.py +0 -0
  87. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/cli/workspace.py +0 -0
  88. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/config.py +0 -0
  89. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/console.py +0 -0
  90. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/constants.py +0 -0
  91. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/context.py +0 -0
  92. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/context_block.py +0 -0
  93. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/__init__.py +0 -0
  94. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/content_blocks.py +0 -0
  95. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/executor.py +0 -0
  96. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/executor_registry.py +0 -0
  97. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/memory.py +0 -0
  98. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/proxy.py +0 -0
  99. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/record_store.py +0 -0
  100. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/sandbox.py +0 -0
  101. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/state.py +0 -0
  102. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/core/tools.py +0 -0
  103. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/events/__init__.py +0 -0
  104. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/events/base.py +0 -0
  105. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/events/bus.py +0 -0
  106. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/events/events.py +0 -0
  107. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/events/helpers.py +0 -0
  108. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/__init__.py +0 -0
  109. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/base.py +0 -0
  110. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/models.py +0 -0
  111. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/reconstruction.py +0 -0
  112. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/registry.py +0 -0
  113. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/sqlite_backend.py +0 -0
  114. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/sqlite_conn.py +0 -0
  115. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/sqlite_schema.py +0 -0
  116. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/history/ui_events.py +0 -0
  117. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/hooks.py +0 -0
  118. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/interaction.py +0 -0
  119. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/md_agents.py +0 -0
  120. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/models.py +0 -0
  121. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/options.py +0 -0
  122. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/permissions.py +0 -0
  123. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/plugins.py +0 -0
  124. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/prompt_xml.py +0 -0
  125. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/__init__.py +0 -0
  126. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/base.py +0 -0
  127. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/model_cache.py +0 -0
  128. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/model_registry.py +0 -0
  129. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/ollama.py +0 -0
  130. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/providers/openrouter.py +0 -0
  131. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/py.typed +0 -0
  132. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/renderer.py +0 -0
  133. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/schemas/__init__.py +0 -0
  134. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/schemas/agent.schema.json +0 -0
  135. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/__init__.py +0 -0
  136. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/backend.py +0 -0
  137. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/env.py +0 -0
  138. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/exec.py +0 -0
  139. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/file.py +0 -0
  140. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/masking.py +0 -0
  141. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/redaction.py +0 -0
  142. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/registry.py +0 -0
  143. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/secrets/sqlite.py +0 -0
  144. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/shell_tool_config.py +0 -0
  145. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/skill_discovery.py +0 -0
  146. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/templates/AGENTS.md +0 -0
  147. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/templates/IDENTITY.md +0 -0
  148. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/templates/MEMORY.md +0 -0
  149. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/templates/USER.md +0 -0
  150. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/templates/personas/casual-technical.md +0 -0
  151. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/templates/personas/marvin.md +0 -0
  152. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/templates/personas/minimal.md +0 -0
  153. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/daemon_control.py +0 -0
  154. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/history.py +0 -0
  155. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/notify.py +0 -0
  156. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/secrets.py +0 -0
  157. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/sessions.py +0 -0
  158. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/shell.py +0 -0
  159. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/skills.py +0 -0
  160. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tools/time.py +0 -0
  161. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/tsugite.py +0 -0
  162. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/__init__.py +0 -0
  163. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/base.py +0 -0
  164. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/chat.py +0 -0
  165. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/dispatch.py +0 -0
  166. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/helpers.py +0 -0
  167. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/jsonl.py +0 -0
  168. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/plain.py +0 -0
  169. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/repl_chat.py +0 -0
  170. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/repl_commands.py +0 -0
  171. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/repl_completer.py +0 -0
  172. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui/repl_handler.py +0 -0
  173. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui_context.py +0 -0
  174. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/ui_surfaces.py +0 -0
  175. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/usage/__init__.py +0 -0
  176. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/usage/quota.py +0 -0
  177. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/usage/store.py +0 -0
  178. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/user_agent.py +0 -0
  179. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/workspace/__init__.py +0 -0
  180. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/workspace/models.py +0 -0
  181. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/workspace/session.py +0 -0
  182. {tsugite_cli-0.22.1 → tsugite_cli-0.23.0}/tsugite/workspace/templates.py +0 -0
@@ -193,4 +193,6 @@ plugins/tsugite-daemon/tsugite_daemon/web/
193
193
  # Re-include frontend source that generic lib/ rules (incl. contributors' global
194
194
  # gitignores with Python-style `lib/`) would otherwise silently exclude.
195
195
  !plugins/tsugite-daemon/frontend/src/lib/
196
- !plugins/tsugite-daemon/frontend/src/lib/**
196
+ !plugins/tsugite-daemon/frontend/src/lib/**
197
+ # Per-job git worktrees provisioned by the daemon.
198
+ .tsugite-jobs/
@@ -289,7 +289,7 @@ Tsugite supports vision (images), audio, and document understanding through the
289
289
  **How It Works:**
290
290
 
291
291
  1. **URL Attachments** (Images/Documents):
292
- - The provider fetches the URL directly (no download overhead — support varies by provider and content type; see `tsugite/attachments/url.py`)
292
+ - The provider fetches the URL directly (no download overhead, though support varies by provider and content type; see `tsugite/attachments/url.py`)
293
293
  - Example: `tsu run -f https://example.com/chart.png "Describe this chart"`
294
294
  - Automatically detected via HTTP HEAD request
295
295
 
@@ -425,7 +425,7 @@ See `examples/attachment_assign_demo.md` and `examples/attachment_index_demo.md`
425
425
 
426
426
  ### Swappable batteries
427
427
 
428
- Subsystems are pluggable behind a uniform seam: (1) a Protocol, (2) a `tsugite.<area>`
428
+ Subsystems are pluggable behind a uniform interface: (1) a Protocol, (2) a `tsugite.<area>`
429
429
  entry-point group, (3) a `config.<area>.backend` selector resolved via
430
430
  `load_backend_entry_point` (`plugins.py`), with a built-in default. Areas: tools, providers,
431
431
  adapters, secrets, hooks, event_subscribers, history, attachments, sandbox, executors.
@@ -448,12 +448,12 @@ For any non-trivial bug fix or feature, write a failing test **first**. Exceptio
448
448
 
449
449
  The workflow:
450
450
 
451
- 1. **Reproduce before fixing.** Write the smallest test that demonstrates the symptom as reported. Run it. If it passes on master, the hypothesis is wrong — do not write a fix. Investigate further, ask the user for more detail, or add logging.
451
+ 1. **Reproduce before fixing.** Write the smallest test that demonstrates the symptom as reported. Run it. If it passes on master, the hypothesis is wrong. Do not write a fix. Investigate further, ask the user for more detail, or add logging.
452
452
  2. **Implement the fix.** Keep the change minimal. Do not refactor or add adjacent cleanup in the same diff.
453
- 3. **Verify the test is load-bearing.** Temp-revert the production change and re-run. The test must go red again. If it still passes, the test isn't actually exercising the fix — rework it.
453
+ 3. **Verify the test is load-bearing.** Temp-revert the production change and re-run. The test must go red again. If it still passes, the test isn't exercising the fix. Rework it.
454
454
  4. **Cover adjacent cases before declaring done.** When touching a parser, protocol boundary, or state machine, enumerate at least two adjacent input shapes and add tests for them. Narrow tests let bugs sneak in through the cases you didn't think about.
455
455
  5. **Run the local regression suite.** At minimum, the test files next to the modules you changed. Faster than running everything, catches silent breakage of existing tests.
456
- 6. **Honest uncertainty.** If a hypothesis doesn't reproduce, say so. Don't ship a "probably-this" fix with no failing test to anchor it — that's how placebo fixes get merged.
456
+ 6. **Honest uncertainty.** If a hypothesis doesn't reproduce, say so. Don't ship a "probably-this" fix with no failing test to anchor it. That's how placebo fixes get merged.
457
457
 
458
458
  Reproducing tests don't need to be elaborate. A 10-line test that flips red→green is worth more than a 100-line one that nobody understands.
459
459
 
@@ -461,7 +461,7 @@ Reproducing tests don't need to be elaborate. A 10-line test that flips red→gr
461
461
 
462
462
  - **Unit tests**: Individual functions and classes
463
463
  - **Pipeline tests**: Mock only `TsugiteAgent` / the provider's `acompletion` but exercise the full pipeline (parsing → rendering → preparation → tool expansion → execution). Most tests in the suite are this style.
464
- - **Integration tests**: Live under `tests/integration/` (not collected by default — see `pyproject.toml` `norecursedirs`). Run explicitly with `uv run pytest tests/integration/`. Good for concurrency, cwd, and daemon-wiring tests that need real threading or a real workspace.
464
+ - **Integration tests**: Live under `tests/integration/` (not collected by default; see `pyproject.toml` `norecursedirs`). Run explicitly with `uv run pytest tests/integration/`. Good for concurrency, cwd, and daemon-wiring tests that need real threading or a real workspace.
465
465
  - **Smoke tests**: `tests/smoke_test.sh` hits a real LLM API (requires `OPENAI_API_KEY`, not run in CI)
466
466
  - **Fixtures**: `conftest.py` provides shared test data
467
467
  - **Mocking**: Use `@pytest.fixture` for LLM responses
@@ -471,9 +471,16 @@ Reproducing tests don't need to be elaborate. A 10-line test that flips red→gr
471
471
  ## Code Style
472
472
 
473
473
  - Python 3.11+ (type hints on public APIs)
474
- - Line length: 120 characters (Black)
474
+ - Line length: 120 characters (ruff)
475
475
  - Import order: stdlib → third-party → local (blank lines between groups)
476
- - Errors: `ValueError` for input validation, `RuntimeError` for execution failures
476
+ - Errors, by condition:
477
+ - `ValueError` - input validation
478
+ - `RuntimeError` - execution failures
479
+ - `TimeoutError` - timeouts
480
+ - `PermissionError` - policy denials
481
+ - `ToolUnavailableError` - the tool cannot run in this context
482
+ - `FileNotFoundError` / `IsADirectoryError` / `NotADirectoryError` - paths
483
+ - Never wrap a tool body in a blanket `except Exception`
477
484
  - Docstrings: Google style with `Args:` and `Returns:` sections
478
485
  - Pydantic models: Use `extra="forbid"` to catch typos in YAML frontmatter
479
486
 
@@ -485,7 +492,7 @@ Reproducing tests don't need to be elaborate. A 10-line test that flips red→gr
485
492
  4. **Don't hardcode paths**: Use XDG utilities (`get_xdg_config_path()`, etc.)
486
493
  5. **Don't use blocking IO in async**: Use `asyncio.to_thread()` for sync tools
487
494
  6. **Test both sync and async paths**: Many tools support both execution modes
488
- 7. **Don't embed prompts in adapters/code**: Use context variables + conditional blocks in `default.md` instead. Add new context vars in `_build_agent_context()` (base adapter) and default them in `agent_preparation.py`, then use `{% if var %}` in the agent template. This keeps all prompt content in one place and leverages the existing rendering pipeline.
495
+ 7. **Don't embed prompts in adapters/code**: Use context variables + conditional blocks in `default.md` instead. Add new context vars in `_build_agent_context()` (base adapter) and default them in `agent_preparation.py`, then use `{% if var %}` in the agent template. This keeps all prompt content in one place and uses the existing rendering pipeline.
489
496
 
490
497
  ## Web UI
491
498
 
@@ -10,8 +10,8 @@ Use [Conventional Commits](https://www.conventionalcommits.org/). Format:
10
10
  <type>(<scope>): <subject>
11
11
  ```
12
12
 
13
- Keep the subject on one line, lowercase, no trailing period. Prefer no body. When the subject alone
14
- does not say what changed for someone using tsugite, add a `Release-Note:` trailer (below).
13
+ Keep the subject on one line, lowercase, no trailing period, and write it as the line a user would
14
+ read in the release notes. No body, no trailers.
15
15
 
16
16
  ### Allowed types
17
17
 
@@ -46,21 +46,14 @@ Scopes are optional, but should be limited to one of these:
46
46
 
47
47
  ### Release notes
48
48
 
49
- git-cliff (`cliff.toml`) generates the release notes from the commits. A subject that already reads
50
- as the change is the release line; when it reads as the patch instead, add a `Release-Note:` trailer:
49
+ git-cliff (`cliff.toml`) generates the release notes from the commit subjects, so the subject is the
50
+ release line. Write it as what the software does now for the person using it, and it needs nothing
51
+ else. `chore`, `docs`, `test` and `ci` commits never appear.
51
52
 
52
- ```
53
- fix(webui): scope agent artifact panes by session
54
-
55
- Release-Note: Opening an artifact from one chat keeps every other chat's pane as it was.
56
- ```
57
-
58
- One line, present tense, stating what the software does now. No "instead of", no because-clause. A
59
- commit with nothing to tell a user (a test fix, an internal rename) carries no trailer and prints its
60
- subject instead. `chore`, `docs`, `test` and `ci` commits never appear.
61
-
62
- The trailer has to be the last paragraph of the message. When squashing, keep one `Release-Note:`
63
- line at the end of the squashed message; a trailer buried mid-body is not parsed.
53
+ `cliff.toml` also prints a `Release-Note:` trailer in place of the subject when one is present. That
54
+ is an escape hatch for the rare commit whose subject cannot say what a user sees, not something to
55
+ add by default; almost every commit should have none. When one is needed it has to be the last
56
+ paragraph of the message, and a squash keeps at most one.
64
57
 
65
58
  ### Examples
66
59
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.22.1
3
+ Version: 0.23.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tsugite-cli"
3
- version = "0.22.1"
3
+ version = "0.23.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"
@@ -89,7 +89,7 @@ class AgentPreparer:
89
89
  _resolve_attachments caller + frontmatter attachments, removals, dedupe
90
90
  _install_skill_manager workspace-aware SkillManager, made active
91
91
  _run_prefetch frontmatter prefetch tools + agent-list injection
92
- _resolve_paths cwd / invoked_from / workspace_dir
92
+ _resolve_paths CWD / INVOKED_FROM / WORKSPACE_DIR
93
93
  _build_template_context the Jinja context and its framework defaults
94
94
  _expand_tools tool specs + capability-based auto-injection
95
95
  _load_skills auto-load, sticky TTL, trigger matching
@@ -143,14 +143,16 @@ class AgentPreparer:
143
143
  )
144
144
 
145
145
  def _resolve_attachments(
146
- self, agent_config: AgentConfig, attachments: Optional[List[Attachment]]
146
+ self,
147
+ agent_config: AgentConfig,
148
+ attachments: Optional[List[Attachment]],
149
+ paths: Dict[str, Optional[str]],
147
150
  ) -> Tuple[List[Attachment], Dict[str, Any]]:
148
151
  """Merge caller attachments with the agent's own, honoring removals.
149
152
 
150
- Front-matter attachments carry the cache tiers and are the intended
151
- source, so they dedupe ahead of any same-named attachment the caller
152
- passed in. Legacy `-filename` string entries drop a same-named entry.
153
- Front-matter paths resolve via cwd (workspace_path is None in production).
153
+ A front-matter attachment defines its cache tier and replaces a same-named
154
+ caller attachment. Legacy `-filename` string entries drop a same-named
155
+ entry. Front-matter paths render with `paths` and resolve against its CWD.
154
156
  """
155
157
  all_attachments = list(attachments or [])
156
158
 
@@ -158,7 +160,7 @@ class AgentPreparer:
158
160
  if removals:
159
161
  all_attachments = [a for a in all_attachments if a.name not in removals]
160
162
 
161
- loaded, bindings = resolve_agent_config_attachments(keep_items, None)
163
+ loaded, bindings = resolve_agent_config_attachments(keep_items, Path(paths["CWD"]), paths)
162
164
  all_attachments = loaded + all_attachments
163
165
 
164
166
  seen_names: set[str] = set()
@@ -202,8 +204,7 @@ class AgentPreparer:
202
204
  except Exception:
203
205
  prefetch_context = {}
204
206
 
205
- # Default agents call list_available_agents() on demand instead of
206
- # carrying the full list, so this stays opt-in.
207
+ # The agent list is opt-in. Default agents call list_available_agents() on demand.
207
208
  if "available_agents" not in prefetch_context and (
208
209
  agent_config.auto_load_agent_list or agent_config.auto_load_agents
209
210
  ):
@@ -255,7 +256,7 @@ class AgentPreparer:
255
256
  continue
256
257
  meta = registry.get(name)
257
258
  if meta is None:
258
- # Skill vanished (renamed/removed) between turns — drop it.
259
+ # The skill vanished (renamed or removed) between turns.
259
260
  expired_sticky.append(name)
260
261
  continue
261
262
  effective_ttl = meta.ttl if meta.ttl is not None else ttl_default
@@ -299,9 +300,8 @@ class AgentPreparer:
299
300
  ) -> List[Tool]:
300
301
  """Expand the agent's tool specs and apply the capability-based auto-injections.
301
302
 
302
- Interactive tools are added only when something can actually answer, and
303
- stripped otherwise, so a scheduled run cannot offer the model a prompt
304
- nobody will see.
303
+ Interactive tools are added only when something can answer, and stripped
304
+ otherwise.
305
305
  """
306
306
  from tsugite.core.tools import create_tool_from_tsugite
307
307
  from tsugite.interaction import get_interaction_backend
@@ -333,8 +333,8 @@ class AgentPreparer:
333
333
  raise RuntimeError(f"Failed to create tools: {e}") from e
334
334
 
335
335
  @staticmethod
336
- def _resolve_paths(path_context: Optional["PathContext"]) -> Tuple[str, Optional[str], Optional[str]]:
337
- """Return (cwd, invoked_from, workspace_dir) as strings for the template context.
336
+ def _resolve_paths(path_context: Optional["PathContext"]) -> Dict[str, Optional[str]]:
337
+ """CWD, INVOKED_FROM and WORKSPACE_DIR for the template context.
338
338
 
339
339
  The daemon supplies an effective_cwd that differs from the process cwd.
340
340
  """
@@ -342,9 +342,11 @@ class AgentPreparer:
342
342
  cwd = str(path_context.effective_cwd)
343
343
  else:
344
344
  cwd = str(Path.cwd())
345
- invoked_from = str(path_context.invoked_from) if path_context else None
346
- workspace_dir = str(path_context.workspace_dir) if path_context and path_context.workspace_dir else None
347
- return cwd, invoked_from, workspace_dir
345
+ return {
346
+ "CWD": cwd,
347
+ "INVOKED_FROM": str(path_context.invoked_from) if path_context else None,
348
+ "WORKSPACE_DIR": str(path_context.workspace_dir) if path_context and path_context.workspace_dir else None,
349
+ }
348
350
 
349
351
  def _build_template_context(
350
352
  self,
@@ -355,9 +357,7 @@ class AgentPreparer:
355
357
  directive_context: Dict[str, Any],
356
358
  attachment_bindings: Dict[str, Any],
357
359
  interactive_mode: bool,
358
- cwd: str,
359
- invoked_from: Optional[str],
360
- workspace_dir: Optional[str],
360
+ paths: Dict[str, Optional[str]],
361
361
  ) -> Dict[str, Any]:
362
362
  """Assemble the Jinja context every agent template renders against.
363
363
 
@@ -387,9 +387,7 @@ class AgentPreparer:
387
387
  "is_subagent": context.get("is_subagent", False),
388
388
  "parent_agent": context.get("parent_agent", None),
389
389
  "chat_history": context.get("chat_history", []),
390
- "CWD": cwd,
391
- "INVOKED_FROM": invoked_from,
392
- "WORKSPACE_DIR": workspace_dir,
390
+ **paths,
393
391
  }
394
392
 
395
393
  # User-specified attachment `assign:` bindings win over built-in or
@@ -445,7 +443,8 @@ class AgentPreparer:
445
443
 
446
444
  agent_config = agent.config
447
445
 
448
- all_attachments, attachment_bindings = self._resolve_attachments(agent_config, attachments)
446
+ paths = self._resolve_paths(path_context)
447
+ all_attachments, attachment_bindings = self._resolve_attachments(agent_config, attachments, paths)
449
448
 
450
449
  _skill_manager = self._install_skill_manager(agent_config, path_context)
451
450
 
@@ -470,7 +469,6 @@ class AgentPreparer:
470
469
  )
471
470
 
472
471
  interactive_mode = is_interactive()
473
- cwd, invoked_from, workspace_dir = self._resolve_paths(path_context)
474
472
  full_context = self._build_template_context(
475
473
  agent_config=agent_config,
476
474
  prompt=prompt,
@@ -478,9 +476,7 @@ class AgentPreparer:
478
476
  directive_context={**prefetch_context, **tool_context, **exec_context},
479
477
  attachment_bindings=attachment_bindings,
480
478
  interactive_mode=interactive_mode,
481
- cwd=cwd,
482
- invoked_from=invoked_from,
483
- workspace_dir=workspace_dir,
479
+ paths=paths,
484
480
  )
485
481
 
486
482
  renderer = AgentRenderer()
@@ -536,7 +532,7 @@ class AgentPreparer:
536
532
 
537
533
  system_message = build_system_prompt(tools, combined_instructions)
538
534
 
539
- # Add environment context when invoked_from differs from CWD
535
+ cwd, invoked_from = paths["CWD"], paths["INVOKED_FROM"]
540
536
  if invoked_from and invoked_from != cwd:
541
537
  env_block = f"""
542
538
  ## Environment
@@ -142,8 +142,9 @@ def run_python_block(
142
142
  workspace_dir: Optional workspace path for sandbox bind mounts.
143
143
 
144
144
  Raises:
145
- RuntimeError: On timeout, on hard execution failure when not continuing,
146
- or on non-coercible return-value types.
145
+ TimeoutError: On timeout.
146
+ RuntimeError: On hard execution failure when not continuing, or on
147
+ non-coercible return-value types.
147
148
  """
148
149
  safe_locals = _filter_json_safe(locals_dict)
149
150
 
@@ -162,7 +163,7 @@ def run_python_block(
162
163
  try:
163
164
  proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
164
165
  except subprocess.TimeoutExpired as e:
165
- raise RuntimeError(f"tsu:exec block timed out after {timeout}s") from e
166
+ raise TimeoutError(f"tsu:exec block timed out after {timeout}s") from e
166
167
 
167
168
  result_data: Dict[str, Any] = {}
168
169
  if os.path.exists(result_path):
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, List, Optional
8
8
  from rich.console import Console
9
9
 
10
10
  from tsugite.console import get_stderr_console
11
+ from tsugite.exceptions import SandboxToolDeniedError # noqa: F401
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from tsugite.options import ExecutionOptions
@@ -24,8 +25,8 @@ _stderr_console = get_stderr_console()
24
25
  _current_agent_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("current_agent", default=None)
25
26
 
26
27
  # Active sandbox policy. Presence means "this agent is running sandboxed"; tools
27
- # read it to propagate the same isolation or to refuse. A nested run leaking its
28
- # teardown here would fail OPEN, so the reset is load-bearing.
28
+ # read it to propagate the same isolation or to refuse. A nested run that leaks
29
+ # its teardown here fails OPEN.
29
30
  _sandbox_context_var: contextvars.ContextVar[Optional["SandboxContext"]] = contextvars.ContextVar(
30
31
  "sandbox_context", default=None
31
32
  )
@@ -47,11 +48,6 @@ class SandboxContext:
47
48
  workspace_dir: Optional[Path] = None
48
49
 
49
50
 
50
- class SandboxToolDeniedError(RuntimeError):
51
- """Raised when a host-exec tool is refused because the agent runs sandboxed
52
- (see the deny_when_sandboxed decorator)."""
53
-
54
-
55
51
  def set_sandbox_context(ctx: Optional["SandboxContext"]) -> contextvars.Token:
56
52
  """Set (or clear, with None) the active sandbox policy. Returns a reset token."""
57
53
  return _sandbox_context_var.set(ctx)
@@ -646,10 +646,15 @@ async def _execute_agent_with_prompt(
646
646
  try:
647
647
  from tsugite.usage import get_usage_store
648
648
 
649
+ meta = channel_metadata or {}
649
650
  get_usage_store().record(
651
+ session_id=continue_conversation_id,
650
652
  agent=prepared.agent_config.name,
651
653
  model=model_string,
652
- source="cli",
654
+ source=meta.get("source") or "cli",
655
+ schedule_name=meta.get("schedule_id"),
656
+ input_tokens=agent.prompt_tokens,
657
+ output_tokens=agent.completion_tokens,
653
658
  total_tokens=agent.total_tokens,
654
659
  cost_usd=agent.reported_cost,
655
660
  cache_creation_tokens=agent.cache_creation_tokens,
@@ -355,8 +355,8 @@ async def _execute_step_with_retries(
355
355
  step_duration = time.time() - step_start_time
356
356
  return step_result, step_duration
357
357
 
358
- except asyncio.TimeoutError:
359
- error_msg = f"Step timed out after {step.timeout} seconds"
358
+ except asyncio.TimeoutError as e:
359
+ error_msg = str(e) or f"Step timed out after {step.timeout} seconds"
360
360
  errors.append(error_msg)
361
361
  code_executed_this_attempt = False
362
362
  except AgentSkippedError:
@@ -17,12 +17,12 @@ from tsugite.utils import has_glob_chars
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
19
 
20
- def _render_path(template: str) -> Optional[str]:
20
+ def _render_path(template: str, template_vars: Dict[str, Any]) -> Optional[str]:
21
21
  """Render a Jinja path template; return None on render failure."""
22
22
  from tsugite.renderer import AgentRenderer
23
23
 
24
24
  try:
25
- return AgentRenderer().render_string(template)
25
+ return AgentRenderer().render_string(template, template_vars)
26
26
  except Exception as e:
27
27
  logger.debug("Failed to render attachment path %r: %s", template, e)
28
28
  return None
@@ -67,6 +67,7 @@ def _expand_glob(pattern: str, workspace_path: Optional[Path]) -> List[Path]:
67
67
  def resolve_agent_config_attachments(
68
68
  items: List[Union[str, AttachmentSpec]],
69
69
  workspace_path: Optional[Path] = None,
70
+ template_vars: Optional[Dict[str, Any]] = None,
70
71
  ) -> Tuple[List[Attachment], Dict[str, Any]]:
71
72
  """Resolve agent config attachment items to Attachment objects and Jinja bindings.
72
73
 
@@ -76,6 +77,7 @@ def resolve_agent_config_attachments(
76
77
  Args:
77
78
  items: List of strings or AttachmentSpec objects from agent_config.attachments
78
79
  workspace_path: Optional workspace path for resolving relative paths
80
+ template_vars: Jinja variables for path templates (CWD, INVOKED_FROM, WORKSPACE_DIR)
79
81
 
80
82
  Returns:
81
83
  Tuple of (attachments to inject, dict of {assign_name: bound_value}).
@@ -90,13 +92,14 @@ def resolve_agent_config_attachments(
90
92
  file_handler = FileHandler()
91
93
  attachments: List[Attachment] = []
92
94
  bindings: Dict[str, Any] = {}
95
+ template_vars = template_vars or {}
93
96
 
94
97
  for item in items:
95
98
  before = len(attachments)
96
99
  if isinstance(item, str):
97
- _resolve_string_item(item, workspace_path, file_handler, attachments)
100
+ _resolve_string_item(item, workspace_path, file_handler, attachments, template_vars)
98
101
  else:
99
- _resolve_spec(item, workspace_path, file_handler, attachments, bindings)
102
+ _resolve_spec(item, workspace_path, file_handler, attachments, bindings, template_vars)
100
103
  # Tag everything this item produced with its cache tier (a spec's tier;
101
104
  # plain strings are always tier 0). The context turn renders one cache
102
105
  # block per tier so a volatile file only invalidates its own block.
@@ -113,9 +116,10 @@ def _resolve_string_item(
113
116
  workspace_path: Optional[Path],
114
117
  file_handler: "FileHandler",
115
118
  attachments: List[Attachment],
119
+ template_vars: Dict[str, Any],
116
120
  ) -> None:
117
- """Legacy string handling: render Jinja, resolve path, fetch single file."""
118
- rendered = _render_path(template)
121
+ """Render the Jinja path, resolve it, fetch the single file."""
122
+ rendered = _render_path(template, template_vars)
119
123
  if rendered is None:
120
124
  return
121
125
  resolved = _resolve_path(rendered, workspace_path)
@@ -134,9 +138,10 @@ def _resolve_spec(
134
138
  file_handler: "FileHandler",
135
139
  attachments: List[Attachment],
136
140
  bindings: Dict[str, Any],
141
+ template_vars: Dict[str, Any],
137
142
  ) -> None:
138
143
  """Resolve a single AttachmentSpec, populating attachments and bindings."""
139
- rendered = _render_path(spec.path)
144
+ rendered = _render_path(spec.path, template_vars)
140
145
  if rendered is None:
141
146
  if spec.assign:
142
147
  bindings[spec.assign] = None
@@ -170,31 +170,22 @@ class AutoContextHandler(AttachmentHandler):
170
170
  return dirs
171
171
 
172
172
  def _discover_files(self, search_dirs: List[Path], context_files: List[str]) -> List[tuple[Path, str]]:
173
- """Discover context files in search directories.
173
+ """Return every context file on the walk, outermost directory first.
174
174
 
175
- Deduplicates by filename to prefer closer (more specific) directories.
176
-
177
- Args:
178
- search_dirs: Directories to search
179
- context_files: List of filenames to search for
180
-
181
- Returns:
182
- List of (file_path, relative_name) tuples for found files
175
+ Names are relative to the outermost directory that has one. A nested
176
+ copy reads as `repos/app/AGENTS.md` next to the outer `AGENTS.md`.
183
177
  """
184
- found = []
185
- seen = set()
178
+ found: List[tuple[Path, str]] = []
179
+ outermost_dir: Optional[Path] = None
186
180
 
187
- # Search in order (most specific directory first)
188
- for directory in search_dirs:
181
+ for directory in reversed(search_dirs):
189
182
  for filename in context_files:
190
- # Skip if we've already found a file with this name
191
- if filename in seen:
192
- continue
193
-
194
183
  file_path = directory / filename
195
- if file_path.exists() and file_path.is_file():
196
- found.append((file_path, filename))
197
- seen.add(filename)
184
+ if not file_path.is_file():
185
+ continue
186
+ if outermost_dir is None:
187
+ outermost_dir = directory
188
+ found.append((file_path, file_path.relative_to(outermost_dir).as_posix()))
198
189
 
199
190
  return found
200
191
 
@@ -394,6 +394,8 @@ class TsugiteAgent:
394
394
  # cost data at all" (interrupted turn) - only the latter records NULL.
395
395
  self.cost_reported = False
396
396
  self.total_tokens = 0
397
+ self.prompt_tokens = 0
398
+ self.completion_tokens = 0
397
399
  self.last_input_tokens = 0
398
400
  self.cache_creation_tokens = 0
399
401
  self.cache_read_tokens = 0
@@ -1333,6 +1335,8 @@ class TsugiteAgent:
1333
1335
  return cost or 0.0
1334
1336
 
1335
1337
  self.total_tokens += usage.total_tokens
1338
+ self.prompt_tokens += usage.prompt_tokens
1339
+ self.completion_tokens += usage.completion_tokens
1336
1340
  self.last_input_tokens = (
1337
1341
  usage.prompt_tokens + (usage.cache_creation_input_tokens or 0) + (usage.cache_read_input_tokens or 0)
1338
1342
  )
@@ -27,7 +27,6 @@ from .state import load_state, save_state
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
30
- # Helpers re-exported so the harness can import them without duplicating code
31
30
  _HARNESS_IMPORTS = textwrap.dedent("""\
32
31
  import ast
33
32
  import contextlib
@@ -65,6 +64,10 @@ _IPC_HELPER = textwrap.dedent("""\
65
64
  safe[k] = _cap_call_text(repr(v), 500)
66
65
  return safe
67
66
 
67
+ def _ipc_error(error_type, message):
68
+ from tsugite.exceptions import exception_from_name
69
+ return exception_from_name(error_type, message)
70
+
68
71
  def _ipc_call(msg_type, **kwargs):
69
72
  global _call_id_counter
70
73
  _call_id_counter += 1
@@ -78,7 +81,7 @@ _IPC_HELPER = textwrap.dedent("""\
78
81
  raise RuntimeError("IPC: parent closed connection")
79
82
  resp = json.loads(resp_line)
80
83
  if resp.get("error"):
81
- raise RuntimeError(f"IPC tool error: {resp['error']}")
84
+ raise _ipc_error(resp.get("error_type"), resp["error"])
82
85
  return resp.get("result")
83
86
 
84
87
  def _ipc_audit(event, tool, **kwargs):
@@ -877,7 +880,12 @@ with open(RESULT_PATH, "w") as f:
877
880
  result = self._ensure_json_serializable(result)
878
881
  resp = {"call_id": call_id, "result": result, "error": None}
879
882
  except Exception as e:
880
- resp = {"call_id": call_id, "result": None, "error": str(e)}
883
+ resp = {
884
+ "call_id": call_id,
885
+ "result": None,
886
+ "error": str(e),
887
+ "error_type": type(e).__name__,
888
+ }
881
889
  else:
882
890
  resp = {"call_id": call_id, "result": None, "error": f"Unknown tool: {name}"}
883
891
 
@@ -1,5 +1,6 @@
1
1
  """Custom exception classes for Tsugite."""
2
2
 
3
+ import builtins
3
4
  from typing import Any, List, Optional
4
5
 
5
6
 
@@ -25,8 +26,16 @@ class StateSerializationError(RuntimeError):
25
26
  self.reason = reason
26
27
 
27
28
 
29
+ class SandboxToolDeniedError(PermissionError):
30
+ """Raised when a host-exec tool is refused because the agent runs sandboxed."""
31
+
32
+
33
+ class ToolUnavailableError(RuntimeError):
34
+ """Raised when a tool cannot run in the current context."""
35
+
36
+
28
37
  class AgentExecutionError(RuntimeError):
29
- """Exception raised when agent execution fails.
38
+ """Raised when agent execution fails.
30
39
 
31
40
  Includes execution details for debugging and analysis.
32
41
 
@@ -75,3 +84,14 @@ def is_unresumable_history_error(error: BaseException | str) -> bool:
75
84
  never succeed, so the caller must abandon it and start a fresh session.
76
85
  """
77
86
  return "text content blocks must be non-empty" in str(error).lower()
87
+
88
+
89
+ def exception_from_name(name: Optional[str], message: str) -> Exception:
90
+ """Rebuild an exception from a class name looked up in builtins, then this module."""
91
+ cls = (getattr(builtins, name, None) or globals().get(name)) if name else None
92
+ if isinstance(cls, type) and issubclass(cls, Exception):
93
+ try:
94
+ return cls(message)
95
+ except TypeError:
96
+ pass
97
+ return RuntimeError(f"{name}: {message}" if name else message)
@@ -39,10 +39,9 @@ _EFFORT_TO_BUDGET = {
39
39
  # fmt: off
40
40
  _ANTHROPIC_MODELS: dict[str, ModelInfo] = {
41
41
  "anthropic/claude-fable-5": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=10.0, output_cost_per_million=50.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
42
+ "anthropic/claude-fable-5-1": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=10.0, output_cost_per_million=50.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "xhigh", "max"], thinking_style="adaptive"),
42
43
  "anthropic/claude-haiku-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=1.0, output_cost_per_million=5.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
43
44
  "anthropic/claude-haiku-4-5-20251001": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=1.0, output_cost_per_million=5.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
44
- "anthropic/claude-opus-4-1": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
45
- "anthropic/claude-opus-4-1-20250805": ModelInfo(max_input_tokens=200_000, max_output_tokens=32_000, input_cost_per_million=15.0, output_cost_per_million=75.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
46
45
  "anthropic/claude-opus-4-5": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
47
46
  "anthropic/claude-opus-4-5-20251101": ModelInfo(max_input_tokens=200_000, max_output_tokens=64_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),
48
47
  "anthropic/claude-opus-4-6": ModelInfo(max_input_tokens=1_000_000, max_output_tokens=128_000, input_cost_per_million=5.0, output_cost_per_million=25.0, supports_vision=True, supports_reasoning=True, supported_effort_levels=["low", "medium", "high", "max"], thinking_style="budget_tokens"),