tsugite-cli 0.19.2__tar.gz → 0.20.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 (179) hide show
  1. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/AGENTS.md +1 -1
  2. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/PKG-INFO +2 -2
  3. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/pyproject.toml +4 -1
  4. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_inheritance.py +21 -25
  5. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/helpers.py +9 -6
  6. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/runner.py +6 -8
  7. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/__init__.py +23 -0
  8. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/daemon.py +20 -1
  9. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/config.py +2 -1
  10. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/agent.py +86 -1
  11. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/executor.py +70 -3
  12. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/subprocess_executor.py +100 -3
  13. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/events/__init__.py +4 -0
  14. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/events/base.py +2 -0
  15. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/events/events.py +31 -0
  16. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/plugins.py +105 -70
  17. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/__init__.py +12 -7
  18. tsugite_cli-0.20.0/tsugite/tools/daemon_control.py +60 -0
  19. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/jobs.py +1 -4
  20. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/notify.py +9 -6
  21. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/schedule.py +1 -4
  22. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/sessions.py +1 -4
  23. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/shell_tools.py +14 -0
  24. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/skills.py +13 -9
  25. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/base.py +6 -0
  26. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/jsonl.py +23 -0
  27. tsugite_cli-0.20.0/tsugite/ui_surfaces.py +105 -0
  28. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/.gitignore +0 -0
  29. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/CONTRIBUTING.md +0 -0
  30. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/LICENSE +0 -0
  31. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/README.md +0 -0
  32. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/__init__.py +0 -0
  33. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_preparation.py +0 -0
  34. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/__init__.py +0 -0
  35. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/exec_directives.py +0 -0
  36. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/exec_runner.py +0 -0
  37. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/history_integration.py +0 -0
  38. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/metrics.py +0 -0
  39. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/models.py +0 -0
  40. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/steps.py +0 -0
  41. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_runner/validation.py +0 -0
  42. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/agent_utils.py +0 -0
  43. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/approval.py +0 -0
  44. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/__init__.py +0 -0
  45. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/agent_config.py +0 -0
  46. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/auto_context.py +0 -0
  47. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/base.py +0 -0
  48. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/delegation.py +0 -0
  49. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/file.py +0 -0
  50. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/inline.py +0 -0
  51. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/storage.py +0 -0
  52. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/attachments/url.py +0 -0
  53. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_agents/.gitkeep +0 -0
  54. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_agents/code_searcher.md +0 -0
  55. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_agents/default.md +0 -0
  56. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_agents/file_searcher.md +0 -0
  57. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_agents/job_verifier.md +0 -0
  58. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_agents/job_worker.md +0 -0
  59. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_agents/onboard.md +0 -0
  60. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_context.py +0 -0
  61. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/.gitkeep +0 -0
  62. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  63. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  64. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  65. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  66. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  67. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  68. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  69. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  70. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  71. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cache.py +0 -0
  72. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cancellation.py +0 -0
  73. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/agents.py +0 -0
  74. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/attachments.py +0 -0
  75. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/cache.py +0 -0
  76. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/chat.py +0 -0
  77. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/config.py +0 -0
  78. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/exec.py +0 -0
  79. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/helpers.py +0 -0
  80. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/history.py +0 -0
  81. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/init.py +0 -0
  82. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/models.py +0 -0
  83. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/plugins.py +0 -0
  84. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/render.py +0 -0
  85. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/run.py +0 -0
  86. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/secrets.py +0 -0
  87. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/skills.py +0 -0
  88. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/tools.py +0 -0
  89. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/usage.py +0 -0
  90. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/validate.py +0 -0
  91. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/cli/workspace.py +0 -0
  92. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/console.py +0 -0
  93. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/constants.py +0 -0
  94. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/context.py +0 -0
  95. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/__init__.py +0 -0
  96. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/content_blocks.py +0 -0
  97. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/executor_registry.py +0 -0
  98. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/memory.py +0 -0
  99. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/proxy.py +0 -0
  100. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/record_store.py +0 -0
  101. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/sandbox.py +0 -0
  102. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/state.py +0 -0
  103. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/core/tools.py +0 -0
  104. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/events/bus.py +0 -0
  105. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/events/helpers.py +0 -0
  106. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/exceptions.py +0 -0
  107. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/__init__.py +0 -0
  108. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/base.py +0 -0
  109. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/models.py +0 -0
  110. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/reconstruction.py +0 -0
  111. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/registry.py +0 -0
  112. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/sqlite_backend.py +0 -0
  113. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/sqlite_conn.py +0 -0
  114. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/sqlite_schema.py +0 -0
  115. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/history/ui_events.py +0 -0
  116. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/hooks.py +0 -0
  117. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/interaction.py +0 -0
  118. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/md_agents.py +0 -0
  119. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/models.py +0 -0
  120. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/options.py +0 -0
  121. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/permissions.py +0 -0
  122. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/__init__.py +0 -0
  123. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/anthropic.py +0 -0
  124. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/base.py +0 -0
  125. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/model_cache.py +0 -0
  126. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/model_registry.py +0 -0
  127. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/ollama.py +0 -0
  128. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/openai_compat.py +0 -0
  129. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/providers/openrouter.py +0 -0
  130. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/py.typed +0 -0
  131. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/renderer.py +0 -0
  132. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/schemas/__init__.py +0 -0
  133. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/schemas/agent.schema.json +0 -0
  134. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/__init__.py +0 -0
  135. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/backend.py +0 -0
  136. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/env.py +0 -0
  137. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/exec.py +0 -0
  138. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/file.py +0 -0
  139. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/masking.py +0 -0
  140. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/redaction.py +0 -0
  141. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/registry.py +0 -0
  142. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/secrets/sqlite.py +0 -0
  143. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/shell_tool_config.py +0 -0
  144. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/skill_discovery.py +0 -0
  145. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/templates/AGENTS.md +0 -0
  146. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/templates/IDENTITY.md +0 -0
  147. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/templates/MEMORY.md +0 -0
  148. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/templates/USER.md +0 -0
  149. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/templates/personas/casual-technical.md +0 -0
  150. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/templates/personas/marvin.md +0 -0
  151. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/templates/personas/minimal.md +0 -0
  152. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/agents.py +0 -0
  153. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/fs.py +0 -0
  154. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/history.py +0 -0
  155. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/http.py +0 -0
  156. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/interactive.py +0 -0
  157. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/scratchpad.py +0 -0
  158. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/secrets.py +0 -0
  159. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/shell.py +0 -0
  160. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tools/time.py +0 -0
  161. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/tsugite.py +0 -0
  162. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/__init__.py +0 -0
  163. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/chat.py +0 -0
  164. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/dispatch.py +0 -0
  165. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/helpers.py +0 -0
  166. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/plain.py +0 -0
  167. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/repl_chat.py +0 -0
  168. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/repl_commands.py +0 -0
  169. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/repl_completer.py +0 -0
  170. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui/repl_handler.py +0 -0
  171. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/ui_context.py +0 -0
  172. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/usage/__init__.py +0 -0
  173. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/usage/store.py +0 -0
  174. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/user_agent.py +0 -0
  175. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/utils.py +0 -0
  176. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/workspace/__init__.py +0 -0
  177. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/workspace/models.py +0 -0
  178. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/workspace/session.py +0 -0
  179. {tsugite_cli-0.19.2 → tsugite_cli-0.20.0}/tsugite/workspace/templates.py +0 -0
@@ -83,7 +83,7 @@ Tsugite is an agentic CLI that executes AI agents defined as markdown files with
83
83
  - `./{name}.md` (current directory)
84
84
  - `builtin_agents/{name}.md` (package-provided)
85
85
  - Global directories (`~/.config/tsugite/agents/`)
86
- - Merges agent inheritance chains (scalars override, lists merge, dicts deep merge)
86
+ - Merges agent inheritance chains (scalars and dicts override, lists merge)
87
87
 
88
88
  3. **Agent Parsing** (`tsugite/md_agents.py`)
89
89
  - Parses YAML frontmatter → `AgentConfig` (Pydantic model)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.19.2
3
+ Version: 0.20.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.19.2"
3
+ version = "0.20.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"
@@ -93,6 +93,7 @@ tsugite-pty = { workspace = true }
93
93
  tsugite-daemon = { workspace = true }
94
94
  tsugite-discord = { workspace = true }
95
95
  tsugite-cc-driver = { workspace = true }
96
+ tsugite-onlyoffice = { workspace = true }
96
97
 
97
98
  [tool.ruff]
98
99
  target-version = "py311"
@@ -170,6 +171,7 @@ paths = [
170
171
  "plugins/tsugite-codex-cli/tsugite_codex_cli",
171
172
  "plugins/tsugite-daemon/tsugite_daemon",
172
173
  "plugins/tsugite-discord/tsugite_discord",
174
+ "plugins/tsugite-onlyoffice/tsugite_onlyoffice",
173
175
  "plugins/tsugite-pty/tsugite_pty",
174
176
  "plugins/tsugite-sandbox/tsugite_sandbox",
175
177
  "plugins/tsugite-tmux/tsugite_tmux",
@@ -268,4 +270,5 @@ dev = [
268
270
  "tsugite-daemon",
269
271
  "tsugite-discord",
270
272
  "tsugite-cc-driver",
273
+ "tsugite-onlyoffice",
271
274
  ]
@@ -293,18 +293,16 @@ def merge_agent_configs(parent, child):
293
293
  Returns:
294
294
  Merged AgentConfig with child taking precedence
295
295
 
296
- Merge rules:
297
- - Scalars (model, max_turns, reasoning_effort, etc.): child overwrites parent
298
- - Lists (tools, attachments): merge and deduplicate
296
+ Merge rules, applied in order so the later ones win:
297
+ - Every field: child overwrites parent, dicts included (model_kwargs, network, sandbox)
298
+ - Lists (tools, attachments, skill_paths): merge and deduplicate
299
299
  - Lists (prefetch): concatenate (parent first, no deduplication)
300
300
  - Lists of dicts (custom_tools): merge and deduplicate by "name" field
301
301
  - Strings (instructions): concatenate with newline
302
302
  """
303
303
  from .md_agents import AgentConfig
304
304
 
305
- # Merge all field types
306
- merged_data = {}
307
- merged_data.update(merge_scalar_fields(parent, child))
305
+ merged_data = merge_all_fields(parent, child)
308
306
  merged_data.update(merge_list_fields(parent, child))
309
307
  merged_data["instructions"] = merge_instructions(parent, child)
310
308
 
@@ -389,33 +387,29 @@ def _get_default_base_agent_name() -> Optional[str]:
389
387
  return "default"
390
388
 
391
389
 
392
- def merge_scalar_fields(parent, child) -> Dict[str, Any]:
393
- """Merge scalar fields from parent and child configs.
390
+ def merge_all_fields(parent, child) -> Dict[str, Any]:
391
+ """Merge every AgentConfig field, so a newly added one is never dropped.
394
392
 
395
- Child values take precedence when explicitly set.
396
-
397
- Args:
398
- parent: Parent AgentConfig
399
- child: Child AgentConfig
400
-
401
- Returns:
402
- Dict of merged scalar fields
393
+ A child takes precedence when it set the field to something other than `None`
394
+ or `""`, both of which read as "leave this to the parent". `merge_agent_configs`
395
+ then overwrites the fields that have their own strategy.
403
396
  """
404
- return {
405
- "name": child.name if child.name else parent.name,
406
- "description": child.description if child.description else parent.description,
407
- "model": child.model if child.model else parent.model,
408
- "max_turns": child.max_turns if child.max_turns != 5 else parent.max_turns,
409
- "reasoning_effort": child.reasoning_effort if child.reasoning_effort else parent.reasoning_effort,
410
- "auto_load_agent_list": child.auto_load_agent_list or parent.auto_load_agent_list,
411
- }
397
+ merged = {}
398
+ for field_name in type(child).model_fields:
399
+ value = getattr(child, field_name)
400
+ child_wins = field_name in child.model_fields_set and value not in (None, "")
401
+ merged[field_name] = value if child_wins else getattr(parent, field_name)
402
+
403
+ # `extends` names this agent's own parent, so it describes the child alone.
404
+ merged["extends"] = child.extends
405
+ return merged
412
406
 
413
407
 
414
408
  def merge_list_fields(parent, child) -> Dict[str, List]:
415
409
  """Merge list fields from parent and child configs.
416
410
 
417
411
  Different list types have different merge strategies:
418
- - tools, attachments: merge and deduplicate
412
+ - tools, attachments, skill_paths: merge and deduplicate
419
413
  - prefetch: concatenate (parent first)
420
414
  - custom_tools: deduplicate by "name" field
421
415
 
@@ -449,6 +443,7 @@ def merge_list_fields(parent, child) -> Dict[str, List]:
449
443
  merged_attachments = _merge_attachments(parent.attachments, child.attachments)
450
444
  merged_skills = _merge_dedup(parent.auto_load_skills, child.auto_load_skills)
451
445
  merged_auto_load_agents = _merge_dedup(parent.auto_load_agents, child.auto_load_agents)
446
+ merged_skill_paths = _merge_dedup(parent.skill_paths, child.skill_paths)
452
447
 
453
448
  # Custom tools - deduplicate by "name" field (child overrides parent)
454
449
  custom_tool_dict = {}
@@ -462,6 +457,7 @@ def merge_list_fields(parent, child) -> Dict[str, List]:
462
457
  "custom_tools": list(custom_tool_dict.values()),
463
458
  "auto_load_skills": merged_skills,
464
459
  "auto_load_agents": merged_auto_load_agents,
460
+ "skill_paths": merged_skill_paths,
465
461
  }
466
462
 
467
463
 
@@ -302,15 +302,18 @@ def get_ui_handler(custom_logger: Optional[Any]) -> Optional[Any]:
302
302
  def set_multistep_ui_context(custom_logger: Optional[Any], step_number: int, step_name: str, total_steps: int) -> None:
303
303
  """Set multistep context in UI handler if available.
304
304
 
305
+ Only a handler that renders a live progress display tracks this, and the handler
306
+ contract is `handle_event` alone - a plugin may ship one subclassing nothing.
307
+
305
308
  Args:
306
309
  custom_logger: Custom logger instance (may be None)
307
310
  step_number: Current step number
308
311
  step_name: Name of current step
309
312
  total_steps: Total number of steps
310
313
  """
311
- ui_handler = get_ui_handler(custom_logger)
312
- if ui_handler:
313
- ui_handler.set_multistep_context(step_number, step_name, total_steps)
314
+ setter = getattr(get_ui_handler(custom_logger), "set_multistep_context", None)
315
+ if setter:
316
+ setter(step_number, step_name, total_steps)
314
317
 
315
318
 
316
319
  def clear_multistep_ui_context(custom_logger: Optional[Any]) -> None:
@@ -319,9 +322,9 @@ def clear_multistep_ui_context(custom_logger: Optional[Any]) -> None:
319
322
  Args:
320
323
  custom_logger: Custom logger instance (may be None)
321
324
  """
322
- ui_handler = get_ui_handler(custom_logger)
323
- if ui_handler:
324
- ui_handler.clear_multistep_context()
325
+ clearer = getattr(get_ui_handler(custom_logger), "clear_multistep_context", None)
326
+ if clearer:
327
+ clearer()
325
328
 
326
329
 
327
330
  def print_step_progress(
@@ -461,19 +461,17 @@ async def _execute_agent_with_prompt(
461
461
  # Register per-agent custom shell tools (if any)
462
462
  if agent_config.custom_tools:
463
463
  from tsugite.shell_tool_config import parse_tool_definition_from_dict
464
- from tsugite.tools.shell_tools import register_shell_tools
464
+ from tsugite.tools.shell_tools import bind_custom_tools
465
465
 
466
466
  try:
467
467
  custom_tool_definitions = [
468
468
  parse_tool_definition_from_dict(tool_dict) for tool_dict in agent_config.custom_tools
469
469
  ]
470
- register_shell_tools(custom_tool_definitions)
471
-
472
- # Add custom tool names to the tool list
473
- for tool_def in custom_tool_definitions:
474
- from tsugite.core.tools import create_tool_from_tsugite
475
-
476
- tools.append(create_tool_from_tsugite(tool_def.name))
470
+ # This run's own definitions win over anything the registry resolved
471
+ # under the same name for an earlier agent.
472
+ for custom in bind_custom_tools(custom_tool_definitions):
473
+ tools = [t for t in tools if t.name != custom.name]
474
+ tools.append(custom)
477
475
  except Exception as e:
478
476
  event_bus.emit(WarningEvent(message=f"Failed to register custom tools: {e}"))
479
477
 
@@ -17,6 +17,29 @@ app = typer.Typer(
17
17
  console = Console()
18
18
 
19
19
 
20
+ def _print_version(value: bool) -> None:
21
+ if not value:
22
+ return
23
+ from tsugite import __version__
24
+
25
+ console.print(f"Tsugite version {__version__}")
26
+ raise typer.Exit()
27
+
28
+
29
+ @app.callback()
30
+ def main(
31
+ version: bool = typer.Option(
32
+ False,
33
+ "--version",
34
+ "-V",
35
+ callback=_print_version,
36
+ is_eager=True,
37
+ help="Show version information.",
38
+ ),
39
+ ) -> None:
40
+ pass
41
+
42
+
20
43
  @app.command()
21
44
  def version():
22
45
  """Show version information."""
@@ -1,6 +1,9 @@
1
1
  """Daemon management CLI commands."""
2
2
 
3
+ import logging
4
+ import os
3
5
  import re
6
+ import sys
4
7
  from pathlib import Path
5
8
  from typing import Optional
6
9
 
@@ -792,6 +795,14 @@ def token_revoke(
792
795
  raise typer.Exit(1)
793
796
 
794
797
 
798
+ def _reexec(start_cwd: str) -> None:
799
+ """Replace this process with a fresh daemon; exec discards buffered log records, so flush first."""
800
+ console.print("[cyan]Restarting daemon...[/cyan]")
801
+ os.chdir(start_cwd)
802
+ logging.shutdown()
803
+ os.execv(sys.executable, [sys.executable, *sys.argv])
804
+
805
+
795
806
  @daemon_app.callback(invoke_without_command=True)
796
807
  def daemon_main(
797
808
  ctx: typer.Context,
@@ -827,12 +838,20 @@ def daemon_main(
827
838
  if log_to_console is not None:
828
839
  overrides["log_to_console"] = log_to_console
829
840
 
841
+ restarting = False
842
+ # Agent code execution chdirs the whole process around, so the cwd at exec
843
+ # time is not necessarily the one the daemon was started from.
844
+ start_cwd = os.getcwd()
845
+
830
846
  try:
831
- asyncio.run(run_daemon(config, config_overrides=overrides or None))
847
+ restarting = asyncio.run(run_daemon(config, config_overrides=overrides or None))
832
848
  except (KeyboardInterrupt, SystemExit, RuntimeError):
833
849
  pass
834
850
  finally:
835
851
  console.print("\n[yellow]Daemon stopped[/yellow]")
836
852
 
853
+ if restarting:
854
+ _reexec(start_cwd)
855
+
837
856
 
838
857
  __all__ = ["daemon_app"]
@@ -3,7 +3,7 @@
3
3
  import json
4
4
  import os
5
5
  from pathlib import Path
6
- from typing import Callable, Dict, List, Optional
6
+ from typing import Any, Callable, Dict, List, Optional
7
7
 
8
8
  from pydantic import BaseModel, ConfigDict, Field
9
9
 
@@ -149,6 +149,7 @@ class Config(BaseModel):
149
149
  skill_paths: List[str] = Field(default_factory=list)
150
150
  agent_paths: List[str] = Field(default_factory=list)
151
151
  skill_ttl_default: int = 10
152
+ plugins: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
152
153
  secrets: Optional[SecretsConfig] = None
153
154
  history: Optional[HistoryConfig] = None
154
155
  sandbox: Optional[SandboxSettings] = None
@@ -152,6 +152,25 @@ def _has_bare_python_fence(cleaned: str) -> bool:
152
152
  return _BARE_PYTHON_FENCE.search(cleaned) is not None
153
153
 
154
154
 
155
+ # Tool-call envelopes a model may emit as text instead of a ```python-exec
156
+ # block. Nothing here executes them, so such a reply did no work. An envelope
157
+ # carries structure (`<invoke name="...">`, or an opening tag closed by its own
158
+ # counterpart); prose naming the tags is not one.
159
+ _TOOL_CALL_MARKUP = re.compile(
160
+ r"<(?:[a-z]+:)?invoke\s+name\s*="
161
+ r"|<(?:[a-z]+:)?(function_calls|tool_call|tool_use)\b[^>]*>.*?</(?:[a-z]+:)?\1\s*>",
162
+ re.IGNORECASE | re.DOTALL,
163
+ )
164
+
165
+ # Fenced blocks and inline spans - markup quoted as code is shown, not issued.
166
+ _CODE_MARKUP = re.compile(r"^```.*?^```|`[^`\n]+`", re.MULTILINE | re.DOTALL)
167
+
168
+
169
+ def _has_tool_call_markup(content: str) -> bool:
170
+ """True if the text contains a native tool-call envelope outside of code."""
171
+ return bool(content) and _TOOL_CALL_MARKUP.search(_CODE_MARKUP.sub("", content)) is not None
172
+
173
+
155
174
  # Tags the runtime injects into the model's NEXT user message after executing
156
175
  # its code (the execution result, multi-block warning, and budget hints). A
157
176
  # well-behaved model never writes these itself; one that does is impersonating
@@ -198,6 +217,20 @@ def _build_spoofed_runtime_tag_warning() -> str:
198
217
  )
199
218
 
200
219
 
220
+ def _build_unexecuted_tool_call_notice_xml() -> str:
221
+ """Model-visible correction when a no-code response was built out of native
222
+ tool-call markup. Nothing in it ran, so its completion summary describes work
223
+ that never happened; this hands the turn back pointing at the fence that
224
+ actually executes."""
225
+ return (
226
+ "\n<tsugite_unexecuted_tool_call_notice>"
227
+ "Your response contained native tool-call markup. This runtime does not execute it - "
228
+ "nothing ran, and any result or count your response reported is not real. "
229
+ "Call the tools from Python instead: re-emit the work as exactly one ```python-exec block."
230
+ "</tsugite_unexecuted_tool_call_notice>"
231
+ )
232
+
233
+
201
234
  def _build_multi_block_warning_xml(count: int) -> str:
202
235
  """Model-visible note appended to a turn's observation when the agent
203
236
  received N>1 ```python blocks in one response.
@@ -409,6 +442,8 @@ class TsugiteAgent:
409
442
  last_response_text: str = ""
410
443
  turn_num = 0
411
444
  cancelled = False
445
+ corrected_tool_call_markup = False
446
+ unexecuted_tool_call_run = False
412
447
 
413
448
  try:
414
449
  for turn_num in range(self.max_turns):
@@ -467,9 +502,21 @@ class TsugiteAgent:
467
502
  )
468
503
  )
469
504
 
505
+ # Neutralizing the tag is silent otherwise, so a model impersonating
506
+ # the runtime leaves no trace for an operator reading logs or events.
507
+ if turn.spoofed_runtime_tag:
508
+ logger.warning("Response wrote runtime-only tags; neutralized them (step %d)", turn_num + 1)
509
+ if self.event_bus:
510
+ self.event_bus.emit(
511
+ WarningEvent(
512
+ message="Response wrote runtime-only tags; they were neutralized.",
513
+ category="spoofed_runtime_tag",
514
+ step=turn_num + 1,
515
+ )
516
+ )
517
+
470
518
  # No code = the model is done. Its raw text is the answer.
471
519
  if not code or not code.strip():
472
- final_value = last_response_text
473
520
  trailing_notice = ""
474
521
  if multi_block_count:
475
522
  trailing_notice += _build_multi_block_warning_xml(multi_block_count)
@@ -479,6 +526,10 @@ class TsugiteAgent:
479
526
  # it wasn't executed. Nudge it toward the exec fence for next turn.
480
527
  if turn.has_bare_python:
481
528
  trailing_notice += _build_bare_python_notice_xml()
529
+ # Correct the model once rather than accept a fabricated summary.
530
+ unexecuted_tool_call = _has_tool_call_markup(last_response_text)
531
+ if unexecuted_tool_call:
532
+ trailing_notice += _build_unexecuted_tool_call_notice_xml()
482
533
  self.memory.add_step(
483
534
  thought=thought,
484
535
  code="",
@@ -488,6 +539,22 @@ class TsugiteAgent:
488
539
  raw_content=last_response_text,
489
540
  xml_observation=trailing_notice or None,
490
541
  )
542
+ if unexecuted_tool_call and not corrected_tool_call_markup:
543
+ corrected_tool_call_markup = True
544
+ if self.event_bus:
545
+ self.event_bus.emit(
546
+ WarningEvent(
547
+ message=(
548
+ "Response used tool-call markup, which is not executed; "
549
+ "asking the model to re-emit it as a python-exec block."
550
+ ),
551
+ category="unexecuted_tool_call",
552
+ step=turn_num + 1,
553
+ )
554
+ )
555
+ continue
556
+ unexecuted_tool_call_run = unexecuted_tool_call
557
+ final_value = last_response_text
491
558
  break
492
559
 
493
560
  # Cooperative cancel checkpoint (before running a tool/code block):
@@ -575,6 +642,13 @@ class TsugiteAgent:
575
642
  error_message = f"max_turns ({self.max_turns}) reached"
576
643
  if self.event_bus:
577
644
  self.event_bus.emit(WarningEvent(message=error_message, step=turn_num + 1))
645
+ # The model answered with tool-call markup again after being corrected,
646
+ # so the run finished having executed nothing it claimed to do.
647
+ elif unexecuted_tool_call_run:
648
+ status = "error"
649
+ error_message = "model answered with unexecuted tool-call markup; no code ran"
650
+ if self.event_bus:
651
+ self.event_bus.emit(WarningEvent(message=error_message, step=turn_num + 1))
578
652
  else:
579
653
  status = "success"
580
654
  error_message = None
@@ -685,6 +759,7 @@ class TsugiteAgent:
685
759
  duration_ms=duration_ms,
686
760
  tools_called=list(exec_result.tools_called) if exec_result.tools_called else None,
687
761
  tool_calls=tool_calls,
762
+ groups=[{k: _mask_str(v) for k, v in g.items()} for g in exec_result.groups] or None,
688
763
  last_statement_type=exec_result.last_statement_type,
689
764
  return_value_repr=return_value_repr,
690
765
  return_value_type=return_value_type,
@@ -1438,6 +1513,14 @@ config = read_file("config.yaml")
1438
1513
  print(config)
1439
1514
  ```
1440
1515
 
1516
+ When a block does several calls toward one goal, label them with `tsu_group`:
1517
+
1518
+ ```python-exec
1519
+ with tsu_group("read the config files"):
1520
+ for name in ("config.yaml", "defaults.yaml"):
1521
+ print(name, len(read_file(name)))
1522
+ ```
1523
+
1441
1524
  Only ```python-exec blocks are executed. A plain ```python block is treated as
1442
1525
  illustration — it is shown to the user but NOT run — so you can quote or explain
1443
1526
  Python without executing it.
@@ -1477,6 +1560,8 @@ the real result next turn.
1477
1560
  Only JSON-serializable values.
1478
1561
  - For a structured (non-string) return: `return_value({{"status": "ok"}})` — ends the run
1479
1562
  and returns the value as-is. For a plain text answer, just stop using code blocks.
1563
+ - Group per phase of work, not per call: one `tsu_group` around several related calls,
1564
+ and none at all for a block that does one thing.
1480
1565
  {tools_section}
1481
1566
  ## Rules
1482
1567
 
@@ -16,6 +16,8 @@ import pprint
16
16
  import sys
17
17
  import threading
18
18
  import time
19
+ import uuid
20
+ from contextlib import contextmanager
19
21
  from dataclasses import dataclass, field
20
22
  from pathlib import Path
21
23
  from typing import Any, Callable, Dict, Iterator, List, Optional, Protocol, runtime_checkable
@@ -74,6 +76,8 @@ MAX_EXECUTION_OUTPUT_KB = 50
74
76
  # not model input, so they stay small.
75
77
  TOOL_CALL_ARG_MAX = 500
76
78
  TOOL_CALL_OUTPUT_MAX = 2000
79
+ # Titles are LLM-authored and nothing else bounds them before history.
80
+ GROUP_TITLE_MAX = 200
77
81
 
78
82
 
79
83
  def _cap_text(text: str, limit: int) -> str:
@@ -106,6 +110,9 @@ class ExecutionResult:
106
110
  # Per-call records ({tool, arguments, success, duration_ms, output|error}),
107
111
  # capped via TOOL_CALL_ARG_MAX / TOOL_CALL_OUTPUT_MAX.
108
112
  tool_calls: List[Dict[str, Any]] = field(default_factory=list)
113
+ # Named `tsu_group` sections opened this turn, in the order they opened:
114
+ # {group_id, title, parent_group_id, success, duration_ms, error?}.
115
+ groups: List[Dict[str, Any]] = field(default_factory=list)
109
116
  variables_set: Dict[str, str] = field(default_factory=dict) # name -> "type(size)"
110
117
  state_keys: Dict[str, str] = field(default_factory=dict) # persisted state: name -> "type(size)"
111
118
  loaded_skills: Dict[str, str] = field(default_factory=dict) # name -> content
@@ -129,6 +136,13 @@ class ExecutionResult:
129
136
  output = output[:max_bytes]
130
137
  self.truncated = True
131
138
 
139
+ # An explicit return_value() is the other way a turn hands back megabytes;
140
+ # the last-expression path prints instead, so it already rides `output`.
141
+ return_value = None if self.return_value is None else _mask(str(self.return_value))
142
+ if return_value is not None and len(return_value) > max_bytes:
143
+ return_value = return_value[:max_bytes]
144
+ self.truncated = True
145
+
132
146
  status = "error" if self.error else "success"
133
147
  attrs = f'status="{status}"'
134
148
  if duration_ms:
@@ -156,8 +170,8 @@ class ExecutionResult:
156
170
  state_list = ", ".join(f"{escape(k)}={escape(_mask(v))}" for k, v in self.state_keys.items())
157
171
  parts.append(f"<state>{state_list}</state>")
158
172
 
159
- if self.return_value is not None:
160
- parts.append(f"<return_value>{escape(_mask(str(self.return_value)))}</return_value>")
173
+ if return_value is not None:
174
+ parts.append(f"<return_value>{escape(return_value)}</return_value>")
161
175
 
162
176
  parts.append("</tsugite_execution_result>")
163
177
  return "\n".join(parts)
@@ -328,6 +342,8 @@ class LocalExecutor:
328
342
  self._return_value = None
329
343
  self._tools_called = []
330
344
  self._tool_calls: List[Dict[str, Any]] = []
345
+ self._group_stack: List[str] = []
346
+ self._groups: List[Dict[str, Any]] = []
331
347
  self._loaded_skills_for_turn: Dict[str, str] = {}
332
348
  self._unloaded_skills_for_turn: List[str] = []
333
349
  self.workspace_dir = workspace_dir
@@ -377,6 +393,7 @@ class LocalExecutor:
377
393
  return f"Message sent: {msg}"
378
394
 
379
395
  ns["send_message"] = send_message
396
+ ns["tsu_group"] = self._tsu_group
380
397
 
381
398
  def _blocked_open(*args, **kwargs):
382
399
  raise RuntimeError(
@@ -475,6 +492,8 @@ class LocalExecutor:
475
492
  self._return_value = None
476
493
  self._tools_called = []
477
494
  self._tool_calls = []
495
+ self._group_stack = []
496
+ self._groups = []
478
497
  self._loaded_skills_for_turn = {}
479
498
  self._unloaded_skills_for_turn = []
480
499
 
@@ -565,6 +584,7 @@ class LocalExecutor:
565
584
  return_value=None if exec_error else self._return_value,
566
585
  tools_called=self._tools_called.copy(),
567
586
  tool_calls=[dict(c) for c in self._tool_calls],
587
+ groups=[dict(g) for g in self._groups],
568
588
  variables_set=variables_set,
569
589
  state_keys=state_keys,
570
590
  loaded_skills=self._loaded_skills_for_turn.copy(),
@@ -619,6 +639,50 @@ class LocalExecutor:
619
639
  wrappers = {t.name: self._make_tool_wrapper(t) for t in tools if t.name not in EXECUTOR_BUILTIN_TOOLS}
620
640
  self.register_tools(wrappers)
621
641
 
642
+ @contextmanager
643
+ def _tsu_group(self, title: str):
644
+ """Bracket the block as a named group on the event stream and the execution result."""
645
+ from tsugite.events import ExecutionGroupEndEvent, ExecutionGroupStartEvent
646
+
647
+ record: Dict[str, Any] = {
648
+ "group_id": uuid.uuid4().hex[:12],
649
+ "title": _cap_text(str(title), GROUP_TITLE_MAX),
650
+ "parent_group_id": self._current_group(),
651
+ }
652
+ self._groups.append(record)
653
+ if self.event_bus:
654
+ self.event_bus.emit(
655
+ ExecutionGroupStartEvent(
656
+ group_id=record["group_id"],
657
+ title=record["title"],
658
+ parent_group_id=record["parent_group_id"],
659
+ )
660
+ )
661
+ self._group_stack.append(record["group_id"])
662
+ t0 = time.perf_counter()
663
+ try:
664
+ yield
665
+ record["success"] = True
666
+ except BaseException as exc:
667
+ record["success"] = False
668
+ record["error"] = _cap_text(f"{type(exc).__name__}: {exc}", TOOL_CALL_ARG_MAX)
669
+ raise
670
+ finally:
671
+ self._group_stack.pop()
672
+ record["duration_ms"] = int((time.perf_counter() - t0) * 1000)
673
+ if self.event_bus:
674
+ self.event_bus.emit(
675
+ ExecutionGroupEndEvent(
676
+ group_id=record["group_id"],
677
+ success=record.get("success", False),
678
+ duration_ms=record["duration_ms"],
679
+ error=record.get("error"),
680
+ )
681
+ )
682
+
683
+ def _current_group(self) -> Optional[str]:
684
+ return self._group_stack[-1] if self._group_stack else None
685
+
622
686
  def _make_tool_wrapper(self, tool_obj):
623
687
  """Build the sync namespace wrapper that calls a tool's async execute()."""
624
688
  from tsugite.events import ToolCallEvent, ToolResultEvent
@@ -630,10 +694,13 @@ class LocalExecutor:
630
694
  # capped repr() strings, and a token baked into one of those is no
631
695
  # longer reachable by key or path.
632
696
  audit_args = redact_sensitive_obj(kwargs, getattr(tool_obj, "sensitive_paths", ()))
697
+ group = self._current_group()
633
698
  record: Dict[str, Any] = {"tool": tool_obj.name, "arguments": _jsonable_call_args(audit_args)}
699
+ if group:
700
+ record["group_id"] = group
634
701
  self._tool_calls.append(record)
635
702
  if self.event_bus:
636
- self.event_bus.emit(ToolCallEvent(tool_name=tool_obj.name, arguments=audit_args))
703
+ self.event_bus.emit(ToolCallEvent(tool_name=tool_obj.name, arguments=audit_args, group_id=group))
637
704
  t0 = time.perf_counter()
638
705
  try:
639
706
  result = run_async_in_sync_context(tool_obj.execute(**kwargs))