tau-coding-agent 0.1.0__py3-none-any.whl

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 (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
tau/engine/service.py ADDED
@@ -0,0 +1,768 @@
1
+ from __future__ import annotations
2
+ from tau.message.types import ToolResultContent
3
+ import asyncio
4
+ from contextlib import aclosing
5
+ from typing import TYPE_CHECKING, Optional, Callable, Coroutine, List, Any
6
+ from pathlib import Path
7
+ from tau.hooks.service import Hooks
8
+ from tau.engine.types import (
9
+ EmitEvent, TurnStartEvent, TurnEndEvent,
10
+ MessageStartEvent, MessageUpdateEvent, MessageEndEvent,
11
+ ToolExecutionStartEvent, ToolExecutionUpdateEvent, ToolExecutionEndEvent,
12
+ AgentStartEvent, AgentEndEvent, AgentErrorEvent,
13
+ ToolExecutionFailureEvent,
14
+ EngineState, EngineOptions, AgentEvent, FollowupQueue, SteeringQueue, AbortSignal,
15
+ )
16
+ from tau.hooks.engine import AgentEndReason, ToolResultEvent, ToolResultEventResult, MessageRollbackEvent
17
+ from tau.hooks.inference import BeforeProviderRequestEvent, AfterProviderResponseEvent
18
+ from tau.hooks.tui import QueueUpdateEvent
19
+ from tau.inference.types import (
20
+ LLMContext,
21
+ ErrorEvent, EndEvent, TextDeltaEvent, TextEndEvent,
22
+ ThinkingDeltaEvent, ThinkingEndEvent, ToolCallEndEvent, StopReason
23
+ )
24
+ from tau.tool.types import ToolContext, ToolExecutionMode, ToolInvocation, ToolResult
25
+ from tau.message.types import AssistantMessage, ToolCallContent, Role, Usage, LLMMessage, ToolMessage
26
+
27
+ if TYPE_CHECKING:
28
+ from tau.inference.api.text.service import TextLLM as LLM
29
+ from tau.tool.types import Tool
30
+ from tau.agent.types import AgentContext
31
+ from tau.settings.manager import SettingsManager
32
+
33
+
34
+
35
+ class Engine:
36
+ """
37
+ Raw LLM streaming loop and tool execution layer.
38
+
39
+ Knows nothing about sessions, extensions, or compaction — those concerns
40
+ belong to Agent. Callers drive it via run() / run_continue() and observe
41
+ results through the event callbacks wired in Options.
42
+
43
+ Workflow:
44
+
45
+ 1. **Initialization**: Sets up LLM, tools, and state (e.g., EngineState).
46
+ 2. **Loop Execution**: Streams LLM responses, executes tools, and handles events.
47
+ 3. **Continuation**: Resumes from saved state via run_continue().
48
+ 4. **Error Handling**: Catches exceptions and emits AgentErrorEvent.
49
+
50
+ Example Usage::
51
+
52
+ from tau.engine import Agent, AgentState, Options
53
+
54
+ # Initialize
55
+ agent = Agent(cwd=Path("/path"), llm=llm, tools=tools)
56
+
57
+ # Run
58
+ await agent.run(ctx=AgentContext(messages=[...]))
59
+
60
+ # Handle events
61
+ await agent.subscribe(lambda event: print(event))
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ cwd: Path,
67
+ llm: LLM,
68
+ tools: List[Tool],
69
+ system_prompt: Optional[str] = None,
70
+ options: Optional[EngineOptions] = None,
71
+ hooks: Optional[Hooks] = None,
72
+ settings: Optional[SettingsManager] = None,
73
+ ) -> None:
74
+ self.llm = llm
75
+ self.tools = tools
76
+ self.system_prompt = system_prompt
77
+ self.options = options or EngineOptions()
78
+ self.hooks = hooks or Hooks()
79
+ self._tools: dict[str, Tool] = {t.name: t for t in (tools or [])}
80
+ self._settings = settings
81
+ self.tool_context = ToolContext(llm=llm, cwd=cwd, settings=settings)
82
+ self.state = EngineState(
83
+ llm=llm,
84
+ tools=tools,
85
+ system_prompt=system_prompt,
86
+ follow_up_queue=FollowupQueue(mode=self.options.followup_mode),
87
+ steering_queue=SteeringQueue(mode=self.options.steering_mode),
88
+ )
89
+ self._signal: asyncio.Event = asyncio.Event()
90
+ self._subscribers: list = []
91
+ # Set by tools that need to trigger a deferred action after the current
92
+ # turn is fully saved (e.g. reboot). Checked by Agent.invoke() after
93
+ # _run_with_retry() returns — never called mid-turn.
94
+ self._deferred_fn: Callable[[], Coroutine] | None = None
95
+
96
+ async def subscribe(self, handler) -> Callable[[], None]:
97
+ """Register an event handler (sync or async).
98
+
99
+ Args:
100
+ handler: A callable that receives AgentEvent objects.
101
+
102
+ Returns:
103
+ An unsubscribe callable that removes the handler when invoked.
104
+ """
105
+ self._subscribers.append(handler)
106
+
107
+ def unsubscribe() -> None:
108
+ if handler in self._subscribers:
109
+ self._subscribers.remove(handler)
110
+
111
+ return unsubscribe
112
+
113
+ async def steer(self, message: LLMMessage) -> None:
114
+ """Enqueue a steering message to be injected after the next tool-call round-trip.
115
+
116
+ Args:
117
+ message: An LLM message to inject into the context.
118
+ """
119
+ if self.state.steering_queue:
120
+ await self.state.steering_queue.enqueue(message)
121
+ await self.hooks.emit(QueueUpdateEvent(
122
+ queue='steering',
123
+ message=message,
124
+ messages=self.state.steering_queue.snapshot(),
125
+ ))
126
+
127
+ async def follow_up(self, message: LLMMessage) -> None:
128
+ """Enqueue a follow-up message to be injected after the current stop-reason=Stop turn.
129
+
130
+ Args:
131
+ message: An LLM message to inject after the agent finishes naturally.
132
+ """
133
+ if self.state.follow_up_queue:
134
+ await self.state.follow_up_queue.enqueue(message)
135
+ await self.hooks.emit(QueueUpdateEvent(
136
+ queue='followup',
137
+ message=message,
138
+ messages=self.state.follow_up_queue.snapshot(),
139
+ ))
140
+
141
+ def clear_steering(self) -> None:
142
+ """Discard all pending steering messages without consuming them."""
143
+ if self.state.steering_queue:
144
+ self.state.steering_queue.clear()
145
+
146
+ def clear_follow_up(self) -> None:
147
+ """Discard all pending follow-up messages without consuming them."""
148
+ if self.state.follow_up_queue:
149
+ self.state.follow_up_queue.clear()
150
+
151
+ def clear_all_queues(self) -> None:
152
+ """Discard all queued steering and follow-up messages."""
153
+ if self.state.steering_queue:
154
+ self.state.steering_queue.clear()
155
+ if self.state.follow_up_queue:
156
+ self.state.follow_up_queue.clear()
157
+
158
+ def has_pending_messages(self) -> bool:
159
+ """True if the steering or follow-up queue has messages waiting to be consumed."""
160
+ steering_has = self.state.steering_queue is not None and not self.state.steering_queue.is_empty()
161
+ followup_has = self.state.follow_up_queue is not None and not self.state.follow_up_queue.is_empty()
162
+ return steering_has or followup_has
163
+
164
+ def reset(self) -> None:
165
+ """Clear transient turn state so the engine can be re-run after an error."""
166
+ if self.state.follow_up_queue:
167
+ self.state.follow_up_queue.clear()
168
+ if self.state.steering_queue:
169
+ self.state.steering_queue.clear()
170
+ self.state.error_message = None
171
+ self.state.pending_tool_calls.clear()
172
+ self.state.is_streaming = False
173
+ self.state.idle_event.set()
174
+
175
+ def set_llm(self, llm: "LLM") -> None:
176
+ """Swap the active LLM. Only safe to call when the engine is idle.
177
+
178
+ Args:
179
+ llm: The new TextLLM instance to use for subsequent turns.
180
+
181
+ Raises:
182
+ RuntimeError: If the engine is currently streaming.
183
+ """
184
+ if self.state.is_streaming:
185
+ raise RuntimeError("Cannot change model while agent is running.")
186
+ self.llm = llm
187
+ self.tool_context = ToolContext(llm=llm, cwd=self.tool_context.cwd, settings=self._settings)
188
+ self.state.llm = llm
189
+
190
+ def abort(self) -> None:
191
+ """Signal the running loop to stop at the next safe check point."""
192
+ self._signal.set()
193
+
194
+ @property
195
+ def is_idle(self) -> bool:
196
+ """True when no streaming loop is active; safe to call run() or run_continue()."""
197
+ return not self.state.is_streaming
198
+
199
+ async def wait_for_idle(self) -> None:
200
+ """Wait until the streaming loop exits."""
201
+ await self.state.idle_event.wait()
202
+
203
+ async def process_events(self, event: AgentEvent) -> None:
204
+ """Update engine state from an event and broadcast it to hooks and subscribers.
205
+
206
+ Args:
207
+ event: An AgentEvent to process and emit.
208
+ """
209
+ match event:
210
+ case MessageStartEvent(message=message):
211
+ self.state.streaming_message = message
212
+ case MessageUpdateEvent(message=message):
213
+ self.state.streaming_message = message
214
+ case MessageEndEvent(message=message):
215
+ self.state.streaming_message = None
216
+ if message:
217
+ self.state.messages.append(message)
218
+ case MessageRollbackEvent(count=count):
219
+ if count > 0:
220
+ del self.state.messages[-count:]
221
+ case ToolExecutionStartEvent(tool_call=tool_call):
222
+ self.state.pending_tool_calls.add(tool_call.id)
223
+ case ToolExecutionEndEvent(tool_result=tool_result):
224
+ self.state.pending_tool_calls.discard(tool_result.id)
225
+ case AgentErrorEvent(error=error):
226
+ self.state.error_message = error
227
+
228
+ await self.hooks.emit(event)
229
+ if self.options.on_event is not None:
230
+ await self.options.on_event(event)
231
+ for handler in list(self._subscribers):
232
+ result = handler(event)
233
+ if asyncio.iscoroutine(result):
234
+ await result
235
+
236
+ # -------------------------------------------------------------------------
237
+ # Tool execution
238
+ # -------------------------------------------------------------------------
239
+
240
+ async def _execute(
241
+ self,
242
+ tool_call: ToolCallContent,
243
+ emit: EmitEvent,
244
+ signal: Optional[AbortSignal],
245
+ ) -> ToolResultContent:
246
+ """Validate, run before/after hooks, and execute a single tool call.
247
+
248
+ Args:
249
+ tool_call: The tool call to execute.
250
+ emit: Callback to emit execution events.
251
+ signal: Abort signal to check for cancellation.
252
+
253
+ Returns:
254
+ A ToolResultContent with the tool's result or an error message.
255
+ """
256
+ if self.options.should_skip_tool_calls is not None:
257
+ return self.options.should_skip_tool_calls(tool_call)
258
+
259
+ tool = self._tools.get(tool_call.name)
260
+ if tool is None:
261
+ return ToolResultContent(
262
+ id=tool_call.id, is_error=True,
263
+ content=f"Tool '{tool_call.name}' not found.", metadata={},
264
+ )
265
+
266
+ tool_call.kind = tool.kind
267
+ ok, errors = tool.validate(params=tool_call.args)
268
+ if not ok:
269
+ content = f"Invalid parameters for '{tool_call.name}':\n{chr(10).join(errors)}"
270
+ return ToolResultContent(id=tool_call.id, is_error=True, content=content, metadata={})
271
+
272
+ args = tool_call.args
273
+ if tool.prepare_arguments is not None:
274
+ args = tool.prepare_arguments(args) or args
275
+
276
+ invocation = ToolInvocation(id=tool_call.id, params=args, name=tool_call.name, cwd=self.tool_context.cwd)
277
+
278
+ # before hook — returning ToolResultContent cancels execution
279
+ if self.options.before_tool_call is not None:
280
+ before_result = await self.options.before_tool_call(invocation, signal)
281
+ if isinstance(before_result, ToolResultContent):
282
+ await emit(ToolExecutionEndEvent(tool_result=before_result))
283
+ return before_result
284
+ elif before_result is not None:
285
+ invocation = before_result
286
+
287
+ async def on_update(partial: ToolResult) -> None:
288
+ await emit(ToolExecutionUpdateEvent(partial_tool_result=partial))
289
+
290
+ tool_result: ToolResultContent
291
+ try:
292
+ await emit(ToolExecutionStartEvent(tool_call=tool_call))
293
+ raw = await tool.execute(
294
+ invocation=invocation,
295
+ tool_execution_update_callback=on_update,
296
+ signal=signal,
297
+ context=self.tool_context,
298
+ )
299
+ if self.options.after_tool_call is not None:
300
+ raw = await self.options.after_tool_call(invocation, raw, signal) or raw
301
+ tool_result = ToolResultContent(
302
+ id=tool_call.id, is_error=raw.is_error,
303
+ content=raw.content, metadata=raw.metadata,
304
+ terminate=raw.terminate,
305
+ terminate_message=raw.terminate_message,
306
+ tool_name=tool_call.name,
307
+ )
308
+ except Exception as e:
309
+ error = f"Tool '{tool_call.name}' execution failed:\n{e}"
310
+ tool_result = ToolResultContent(
311
+ id=tool_call.id, is_error=True, content=error, metadata={},
312
+ tool_name=tool_call.name,
313
+ )
314
+ await emit(ToolExecutionFailureEvent(
315
+ tool_name=tool_call.name,
316
+ tool_call_id=tool_call.id,
317
+ input=tool_call.args,
318
+ error=error,
319
+ ))
320
+
321
+ await emit(ToolExecutionEndEvent(tool_result=tool_result))
322
+
323
+ hook_results = await self.hooks.emit(ToolResultEvent(
324
+ tool_call_id=tool_call.id,
325
+ tool_name=tool_call.name,
326
+ input=tool_call.args,
327
+ content=tool_result.content,
328
+ is_error=tool_result.is_error,
329
+ ))
330
+ for r in hook_results:
331
+ if isinstance(r, ToolResultEventResult):
332
+ if r.content is not None:
333
+ tool_result.content = r.content
334
+ if r.is_error is not None:
335
+ tool_result.is_error = r.is_error
336
+ if r.terminate:
337
+ tool_result.terminate = True
338
+ if r.metadata is not None:
339
+ tool_result.metadata = {**(tool_result.metadata or {}), **r.metadata}
340
+ break
341
+
342
+ return tool_result
343
+
344
+ async def _sequential_execute(
345
+ self,
346
+ tool_calls: list[ToolCallContent],
347
+ emit: EmitEvent,
348
+ signal: Optional[AbortSignal],
349
+ ) -> list[ToolResultContent]:
350
+ """Execute tool calls one at a time, preserving invocation order.
351
+
352
+ Args:
353
+ tool_calls: List of tool calls to execute sequentially.
354
+ emit: Callback to emit execution events.
355
+ signal: Abort signal to check for cancellation.
356
+
357
+ Returns:
358
+ List of ToolResultContent in the same order as tool_calls.
359
+ """
360
+ results = []
361
+ for tc in tool_calls:
362
+ # Stop launching further tools once the user aborts; the interrupted
363
+ # turn is dropped wholesale, so partial results aren't needed.
364
+ if signal is not None and signal.is_set():
365
+ break
366
+ results.append(await self._execute(tc, emit, signal))
367
+ return results
368
+
369
+ async def _parallel_execute(
370
+ self,
371
+ tool_calls: list[ToolCallContent],
372
+ emit: EmitEvent,
373
+ signal: Optional[AbortSignal],
374
+ ) -> list[ToolResultContent]:
375
+ """Execute all tool calls concurrently via asyncio.gather.
376
+
377
+ Args:
378
+ tool_calls: List of tool calls to execute in parallel.
379
+ emit: Callback to emit execution events.
380
+ signal: Abort signal to check for cancellation.
381
+
382
+ Returns:
383
+ List of ToolResultContent (order may differ from input).
384
+ """
385
+ return list(await asyncio.gather(
386
+ *[self._execute(tc, emit, signal) for tc in tool_calls]
387
+ ))
388
+
389
+ async def _batch_execute(
390
+ self,
391
+ tool_calls: list[ToolCallContent],
392
+ emit: EmitEvent,
393
+ signal: Optional[AbortSignal],
394
+ ) -> list[ToolResultContent]:
395
+ """Split tool calls by each tool's own execution_mode, run parallel group
396
+ concurrently and sequential group one-at-a-time, then merge results."""
397
+ results: list[ToolResultContent] = []
398
+ parallel_calls: list[ToolCallContent] = []
399
+ sequential_calls: list[ToolCallContent] = []
400
+ for tc in tool_calls:
401
+ tool = self._tools.get(tc.name)
402
+ if tool is None:
403
+ results.append(ToolResultContent(
404
+ id=tc.id, is_error=True,
405
+ content=f"Tool '{tc.name}' not found.", metadata={},
406
+ ))
407
+ continue
408
+ match tool.execution_mode:
409
+ case ToolExecutionMode.Parallel:
410
+ parallel_calls.append(tc)
411
+ case ToolExecutionMode.Sequential | _:
412
+ sequential_calls.append(tc)
413
+ if parallel_calls:
414
+ results.extend(await self._parallel_execute(parallel_calls, emit, signal))
415
+ if sequential_calls:
416
+ results.extend(await self._sequential_execute(sequential_calls, emit, signal))
417
+ return results
418
+
419
+ async def _execute_tool_calls(
420
+ self,
421
+ tool_calls: list[ToolCallContent],
422
+ emit: EmitEvent,
423
+ signal: Optional[AbortSignal] = None,
424
+ ) -> list[ToolResultContent]:
425
+ """Dispatch tool calls according to the configured execution mode."""
426
+ match self.options.execution_mode:
427
+ case ToolExecutionMode.Parallel:
428
+ return await self._parallel_execute(tool_calls, emit, signal)
429
+ case ToolExecutionMode.Batch:
430
+ return await self._batch_execute(tool_calls, emit, signal)
431
+ case ToolExecutionMode.Sequential | _:
432
+ return await self._sequential_execute(tool_calls, emit, signal)
433
+
434
+ # -------------------------------------------------------------------------
435
+ # Main loop
436
+ # -------------------------------------------------------------------------
437
+
438
+ @staticmethod
439
+ async def _iter_with_abort(stream: Any, signal: AbortSignal):
440
+ """Yield events from ``stream`` while honouring ``signal`` immediately.
441
+
442
+ ``async for event in stream`` only re-checks the abort signal once the
443
+ next event arrives, so an abort issued while the coroutine is suspended
444
+ awaiting the network (the initial API call / thinking phase, or between
445
+ sparse chunks) wouldn't take effect until the next chunk. Here each
446
+ ``__anext__`` is raced against ``signal.wait()`` so the in-flight read is
447
+ cancelled the moment abort fires; the caller's ``aclosing`` then tears
448
+ down the underlying request.
449
+ """
450
+ stream_iter = stream.__aiter__()
451
+ signal_task = asyncio.ensure_future(signal.wait())
452
+ try:
453
+ while not signal.is_set():
454
+ event_task = asyncio.ensure_future(stream_iter.__anext__())
455
+ done, _ = await asyncio.wait(
456
+ {event_task, signal_task},
457
+ return_when=asyncio.FIRST_COMPLETED,
458
+ )
459
+ if event_task not in done:
460
+ # Abort won the race — cancel the pending read and stop.
461
+ event_task.cancel()
462
+ try:
463
+ await event_task
464
+ except (asyncio.CancelledError, StopAsyncIteration):
465
+ pass
466
+ return
467
+ try:
468
+ event = event_task.result()
469
+ except StopAsyncIteration:
470
+ return
471
+ yield event
472
+ finally:
473
+ if not signal_task.done():
474
+ signal_task.cancel()
475
+ try:
476
+ await signal_task
477
+ except asyncio.CancelledError:
478
+ pass
479
+
480
+ async def _loop(self, messages: list[LLMMessage], emit: EmitEvent, signal: AbortSignal) -> None:
481
+ """Core agentic loop: stream LLM → execute tools → inject steering/follow-ups → repeat until done.
482
+
483
+ Args:
484
+ messages: Conversation history to pass to the LLM.
485
+ emit: Callback to emit engine events.
486
+ signal: Abort signal to check for user-initiated cancellation.
487
+ """
488
+ await emit(AgentStartEvent())
489
+
490
+ tool_calls: list[ToolCallContent] = []
491
+ tool_results: list[ToolResultContent] = []
492
+ end_reason: AgentEndReason = AgentEndReason.Completed
493
+
494
+ try:
495
+ while True:
496
+ await emit(TurnStartEvent())
497
+ message = AssistantMessage()
498
+ tool_calls.clear()
499
+
500
+ ctx_messages = list(messages)
501
+
502
+ if self.options.transform_context is not None:
503
+ # Allows callers (e.g. Agent) to inject compaction or strip unusable trailing messages.
504
+ ctx_messages = self.options.transform_context(ctx_messages, signal)
505
+
506
+ if signal.is_set():
507
+ closing = AssistantMessage(stop_reason=StopReason.Abort)
508
+ await emit(MessageStartEvent(message=closing))
509
+ await emit(MessageEndEvent(message=closing))
510
+ messages.append(closing)
511
+ await emit(TurnEndEvent(message=closing, tool_results=tool_results))
512
+ break
513
+
514
+ ctx=LLMContext(
515
+ messages=ctx_messages,
516
+ tools=self.state.tools,
517
+ system_prompt=self.state.system_prompt,
518
+ )
519
+
520
+ await emit(MessageStartEvent(message=message))
521
+ await self.hooks.emit(BeforeProviderRequestEvent(
522
+ model=self.llm.model,
523
+ messages=ctx_messages,
524
+ options=self.llm.api.options,
525
+ ))
526
+
527
+ async with aclosing(self.llm.stream(ctx)) as stream:
528
+ _streaming_text: Any = None
529
+ _streaming_thinking: Any = None
530
+ async for event in self._iter_with_abort(stream, signal):
531
+ match event:
532
+ case ToolCallEndEvent(tool_call=tool_call):
533
+ tool_calls.append(tool_call)
534
+ message.contents.append(tool_call)
535
+ # Surface the tool call to the UI as it streams and
536
+ # mark the turn as having content, so an abort here is
537
+ # treated as mid-stream (not a pre-stream undo).
538
+ await emit(MessageUpdateEvent(message=message))
539
+ case TextDeltaEvent(text=text):
540
+ if _streaming_text is None:
541
+ from tau.message.types import TextContent
542
+ _streaming_text = TextContent(content=text.content)
543
+ message.contents.append(_streaming_text)
544
+ else:
545
+ _streaming_text.content += text.content
546
+ await emit(MessageUpdateEvent(message=message))
547
+ case ThinkingDeltaEvent(thinking=thinking):
548
+ if _streaming_thinking is None:
549
+ from tau.message.types import ThinkingContent
550
+ _streaming_thinking = ThinkingContent(content=thinking.content)
551
+ message.contents.append(_streaming_thinking)
552
+ else:
553
+ _streaming_thinking.content += thinking.content
554
+ await emit(MessageUpdateEvent(message=message))
555
+ case TextEndEvent(text=text):
556
+ if _streaming_text is not None:
557
+ _streaming_text.content = text.content
558
+ _streaming_text = None
559
+ else:
560
+ message.contents.append(text)
561
+ case ThinkingEndEvent(thinking=thinking):
562
+ if _streaming_thinking is not None:
563
+ _streaming_thinking.content = thinking.content
564
+ _streaming_thinking = None
565
+ else:
566
+ message.contents.append(thinking)
567
+ case ErrorEvent(reason=reason, error=error):
568
+ message.stop_reason = reason
569
+ message.error = error
570
+ case EndEvent() as ev:
571
+ message.stop_reason = ev.reason
572
+ message.usage = Usage(
573
+ input_tokens=ev.input_tokens,
574
+ output_tokens=ev.output_tokens,
575
+ cache_read_tokens=ev.cache_read_tokens,
576
+ cache_write_tokens=ev.cache_write_tokens,
577
+ )
578
+
579
+ # If we broke out of the stream early due to abort, treat as abort
580
+ if signal.is_set() and message.stop_reason == StopReason.Stop:
581
+ message.stop_reason = StopReason.Abort
582
+
583
+ await self.hooks.emit(AfterProviderResponseEvent(
584
+ model=self.llm.model,
585
+ response=message,
586
+ ))
587
+
588
+ match message.stop_reason:
589
+ case StopReason.Abort:
590
+ # Partial text gives the LLM context about what it was
591
+ # generating when interrupted, so keep it. But if the model
592
+ # had begun a tool call, drop the whole turn — an unfinished
593
+ # tool call that never ran has no matching result and would
594
+ # leave a dangling tool_use. Replace it with a clean marker.
595
+ if message.tool_calls():
596
+ message = AssistantMessage(stop_reason=StopReason.Abort)
597
+ await emit(MessageEndEvent(message=message))
598
+ messages.append(message)
599
+ end_reason = AgentEndReason.Aborted
600
+ await emit(TurnEndEvent(message=message, tool_results=tool_results))
601
+ break
602
+
603
+ case StopReason.Error:
604
+ await emit(MessageEndEvent(message=message))
605
+ err_msg = message.error or f"Turn failed with reason: {message.stop_reason.value}"
606
+ end_reason = AgentEndReason.Error
607
+ await emit(AgentErrorEvent(error=err_msg))
608
+ await emit(TurnEndEvent(message=None, tool_results=tool_results))
609
+ break
610
+
611
+ case StopReason.ToolCalls:
612
+ await emit(MessageEndEvent(message=message))
613
+ messages.append(message)
614
+ tool_results = await self._execute_tool_calls(
615
+ tool_calls=tool_calls,
616
+ emit=emit,
617
+ signal=signal,
618
+ )
619
+ tool_message = ToolMessage.from_results(tool_results)
620
+ await emit(MessageStartEvent(message=tool_message))
621
+ await emit(MessageEndEvent(message=tool_message))
622
+ messages.append(tool_message)
623
+
624
+ # If every tool signalled terminate, stop without another LLM call.
625
+ if tool_results and all(r.terminate for r in tool_results):
626
+ text = "\n".join(
627
+ (r.terminate_message or r.content)
628
+ for r in tool_results
629
+ if (r.terminate_message or r.content)
630
+ )
631
+ if text:
632
+ message=AssistantMessage.from_text(text)
633
+ await emit(MessageEndEvent(message=message))
634
+ messages.append(message)
635
+ await emit(TurnEndEvent(message=message, tool_results=tool_results))
636
+ break
637
+
638
+ if signal.is_set():
639
+ end_reason = AgentEndReason.Aborted
640
+ # Drop the whole interrupted tool turn. The assistant
641
+ # tool-call message and its (possibly partial) tool-result
642
+ # message were already committed before/while tools ran;
643
+ # retract both from history and UI, leaving only a clean
644
+ # interrupt marker — as if the tool turn never happened.
645
+ await emit(MessageRollbackEvent(count=2))
646
+ del messages[-2:]
647
+ message = AssistantMessage(stop_reason=StopReason.Abort)
648
+ await emit(MessageStartEvent(message=message))
649
+ await emit(MessageEndEvent(message=message))
650
+ messages.append(message)
651
+ await emit(TurnEndEvent(message=message, tool_results=[]))
652
+ break
653
+
654
+ steering_messages: list[LLMMessage] = []
655
+ if self.state.steering_queue and not self.state.steering_queue.is_empty():
656
+ steering_messages.extend(await self.state.steering_queue.dequeue())
657
+ if self.options.get_steering_messages is not None:
658
+ steering_messages.extend(self.options.get_steering_messages())
659
+ for msg in steering_messages:
660
+ await emit(MessageStartEvent(message=msg))
661
+ await emit(MessageEndEvent(message=msg))
662
+ messages.append(msg)
663
+
664
+ case StopReason.Stop:
665
+ await emit(MessageEndEvent(message=message))
666
+ messages.append(message)
667
+ follow_up_messages: list[LLMMessage] = []
668
+ if self.state.follow_up_queue and not self.state.follow_up_queue.is_empty():
669
+ follow_up_messages.extend(await self.state.follow_up_queue.dequeue())
670
+ if self.options.get_follow_up_messages is not None:
671
+ follow_up_messages.extend(self.options.get_follow_up_messages())
672
+
673
+ if follow_up_messages:
674
+ for msg in follow_up_messages:
675
+ await emit(MessageStartEvent(message=msg))
676
+ await emit(MessageEndEvent(message=msg))
677
+ messages.append(msg)
678
+ else:
679
+ await emit(TurnEndEvent(message=message, tool_results=tool_results))
680
+ break
681
+ case _:
682
+ pass
683
+
684
+ await emit(TurnEndEvent(message=message, tool_results=tool_results))
685
+
686
+ if self.options.should_stop_after_turn:
687
+ if self.options.should_stop_after_turn(message, tool_results):
688
+ break
689
+
690
+ tool_results.clear()
691
+ except Exception as e:
692
+ end_reason = AgentEndReason.Error
693
+ await emit(AgentErrorEvent(error=str(e)))
694
+
695
+ await emit(AgentEndEvent(messages=messages, reason=end_reason))
696
+
697
+ async def run(self, ctx: AgentContext, signal: Optional[AbortSignal] = None) -> None:
698
+ """Apply context and start a fresh loop. Uses the provided signal or creates one."""
699
+ self._signal = signal if signal is not None else asyncio.Event()
700
+ self.state.is_streaming = True
701
+ self.state.idle_event.clear()
702
+ self.state.system_prompt = ctx.system_prompt
703
+ self.state.tools = ctx.tools
704
+ self._tools = {t.name: t for t in ctx.tools}
705
+ try:
706
+ await self._loop(ctx.messages, self.process_events, self._signal)
707
+ finally:
708
+ self.state.is_streaming = False
709
+ self.state.idle_event.set()
710
+
711
+ async def run_continue(self) -> None:
712
+ """Resume an idle engine from its current message history, draining queued steering/follow-up first.
713
+
714
+ Raises:
715
+ RuntimeError: If the engine is currently streaming or has no messages.
716
+ """
717
+ if self.state.is_streaming:
718
+ raise RuntimeError("Agent is already processing. Wait for completion before continuing.")
719
+
720
+ if not self.state.messages:
721
+ # Edge case: session was reset but follow-up messages were enqueued before any LLM turn.
722
+ if self.state.follow_up_queue and not self.state.follow_up_queue.is_empty():
723
+ follow_up_messages = await self.state.follow_up_queue.dequeue()
724
+ from tau.agent.types import AgentContext
725
+ await self.run(AgentContext(
726
+ system_prompt=self.state.system_prompt or '',
727
+ messages=follow_up_messages,
728
+ ))
729
+ return
730
+ raise RuntimeError("No messages to continue from")
731
+
732
+ last_message = self.state.messages[-1]
733
+ match last_message.role:
734
+ case Role.ASSISTANT:
735
+ if self.state.steering_queue and not self.state.steering_queue.is_empty():
736
+ steering_messages = await self.state.steering_queue.dequeue()
737
+ from tau.agent.types import AgentContext
738
+ await self.run(AgentContext(
739
+ system_prompt=self.state.system_prompt or '',
740
+ messages=self.state.messages + steering_messages,
741
+ ))
742
+ return
743
+
744
+ if self.state.follow_up_queue and not self.state.follow_up_queue.is_empty():
745
+ follow_up_messages = await self.state.follow_up_queue.dequeue()
746
+ from tau.agent.types import AgentContext
747
+ await self.run(AgentContext(
748
+ system_prompt=self.state.system_prompt or '',
749
+ messages=self.state.messages + follow_up_messages,
750
+ ))
751
+ return
752
+
753
+ raise RuntimeError("Cannot continue from message role: assistant")
754
+ case _:
755
+ pass
756
+
757
+ await self._loop_continue()
758
+
759
+ async def _loop_continue(self) -> None:
760
+ """Re-enter the loop with existing state.messages (used when last message is a tool result)."""
761
+ self._signal = asyncio.Event() # standalone re-entry gets its own fresh signal
762
+ self.state.is_streaming = True
763
+ self.state.idle_event.clear()
764
+ try:
765
+ await self._loop(self.state.messages, self.process_events, self._signal)
766
+ finally:
767
+ self.state.is_streaming = False
768
+ self.state.idle_event.set()