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.
- tau/__init__.py +0 -0
- tau/agent/__init__.py +11 -0
- tau/agent/prompt/__init__.py +10 -0
- tau/agent/prompt/builder.py +302 -0
- tau/agent/prompt/types.py +33 -0
- tau/agent/service.py +369 -0
- tau/agent/types.py +61 -0
- tau/auth/manager.py +247 -0
- tau/auth/storage.py +82 -0
- tau/auth/types.py +41 -0
- tau/builtins/__init__.py +4 -0
- tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__init__.py +41 -0
- tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
- tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
- tau/builtins/commands/clear.py +16 -0
- tau/builtins/commands/compact.py +28 -0
- tau/builtins/commands/reload.py +27 -0
- tau/builtins/commands/session.py +19 -0
- tau/builtins/extensions/footer/__init__.py +76 -0
- tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
- tau/builtins/extensions/footer/git.py +26 -0
- tau/builtins/extensions/footer/model.py +69 -0
- tau/builtins/extensions/footer/utils.py +44 -0
- tau/builtins/extensions/header/__init__.py +18 -0
- tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__init__.py +0 -0
- tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/models/audio.py +43 -0
- tau/builtins/models/image.py +43 -0
- tau/builtins/models/text.py +482 -0
- tau/builtins/models/video.py +40 -0
- tau/builtins/prompts/commit.md +7 -0
- tau/builtins/prompts/docs.md +7 -0
- tau/builtins/prompts/explain.md +7 -0
- tau/builtins/prompts/fix.md +7 -0
- tau/builtins/prompts/refactor.md +7 -0
- tau/builtins/prompts/review.md +7 -0
- tau/builtins/prompts/test.md +7 -0
- tau/builtins/providers/__init__.py +0 -0
- tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
- tau/builtins/providers/audio.py +10 -0
- tau/builtins/providers/image.py +9 -0
- tau/builtins/providers/text.py +33 -0
- tau/builtins/providers/video.py +6 -0
- tau/builtins/skills/code-review/SKILL.md +4 -0
- tau/builtins/skills/debug/SKILL.md +4 -0
- tau/builtins/skills/git-commit/SKILL.md +4 -0
- tau/builtins/themes/dark.yaml +1 -0
- tau/builtins/themes/light.yaml +46 -0
- tau/builtins/tools/__init__.py +73 -0
- tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
- tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
- tau/builtins/tools/edit.py +215 -0
- tau/builtins/tools/glob.py +112 -0
- tau/builtins/tools/grep.py +146 -0
- tau/builtins/tools/ls.py +135 -0
- tau/builtins/tools/read.py +122 -0
- tau/builtins/tools/terminal.py +150 -0
- tau/builtins/tools/write.py +105 -0
- tau/commands/__init__.py +10 -0
- tau/commands/registry.py +71 -0
- tau/commands/types.py +33 -0
- tau/console/__init__.py +0 -0
- tau/console/cli.py +266 -0
- tau/console/commands/__init__.py +0 -0
- tau/console/commands/auth.py +193 -0
- tau/console/commands/packages.py +104 -0
- tau/console/commands/update.py +76 -0
- tau/core/__init__.py +0 -0
- tau/core/registry.py +102 -0
- tau/engine/__init__.py +47 -0
- tau/engine/service.py +768 -0
- tau/engine/types.py +163 -0
- tau/extensions/__init__.py +28 -0
- tau/extensions/api.py +928 -0
- tau/extensions/context.py +462 -0
- tau/extensions/events.py +70 -0
- tau/extensions/loader.py +386 -0
- tau/extensions/runtime.py +184 -0
- tau/extensions/settings.py +137 -0
- tau/hooks/__init__.py +112 -0
- tau/hooks/engine.py +237 -0
- tau/hooks/inference.py +21 -0
- tau/hooks/runtime.py +126 -0
- tau/hooks/service.py +121 -0
- tau/hooks/session.py +117 -0
- tau/hooks/tui.py +61 -0
- tau/hooks/types.py +72 -0
- tau/inference/__init__.py +180 -0
- tau/inference/api/__init__.py +0 -0
- tau/inference/api/audio/__init__.py +0 -0
- tau/inference/api/audio/base.py +29 -0
- tau/inference/api/audio/builtins.py +15 -0
- tau/inference/api/audio/elevenlabs_audio.py +183 -0
- tau/inference/api/audio/gemini_audio.py +95 -0
- tau/inference/api/audio/openai_audio.py +159 -0
- tau/inference/api/audio/registry.py +15 -0
- tau/inference/api/audio/sarvam_audio.py +163 -0
- tau/inference/api/audio/service.py +103 -0
- tau/inference/api/audio/utils.py +47 -0
- tau/inference/api/image/__init__.py +0 -0
- tau/inference/api/image/base.py +17 -0
- tau/inference/api/image/builtins.py +8 -0
- tau/inference/api/image/gemini_image.py +77 -0
- tau/inference/api/image/openai_image.py +103 -0
- tau/inference/api/image/openrouter.py +144 -0
- tau/inference/api/image/registry.py +15 -0
- tau/inference/api/image/service.py +71 -0
- tau/inference/api/registry.py +82 -0
- tau/inference/api/text/__init__.py +0 -0
- tau/inference/api/text/anthropic_claude_code.py +222 -0
- tau/inference/api/text/anthropic_messages.py +196 -0
- tau/inference/api/text/base.py +40 -0
- tau/inference/api/text/builtins.py +19 -0
- tau/inference/api/text/gemini_generate.py +234 -0
- tau/inference/api/text/github_copilot_chat.py +172 -0
- tau/inference/api/text/google_antigravity.py +522 -0
- tau/inference/api/text/mistral_chat.py +284 -0
- tau/inference/api/text/ollama_chat.py +200 -0
- tau/inference/api/text/openai_codex_responses.py +497 -0
- tau/inference/api/text/openai_completions.py +227 -0
- tau/inference/api/text/openai_responses.py +235 -0
- tau/inference/api/text/registry.py +50 -0
- tau/inference/api/text/service.py +297 -0
- tau/inference/api/text/types.py +7 -0
- tau/inference/api/text/utils.py +228 -0
- tau/inference/api/video/__init__.py +0 -0
- tau/inference/api/video/base.py +26 -0
- tau/inference/api/video/builtins.py +7 -0
- tau/inference/api/video/fal_video.py +119 -0
- tau/inference/api/video/openrouter_video.py +142 -0
- tau/inference/api/video/registry.py +15 -0
- tau/inference/api/video/service.py +72 -0
- tau/inference/model/__init__.py +0 -0
- tau/inference/model/registry.py +102 -0
- tau/inference/model/types.py +65 -0
- tau/inference/provider/__init__.py +0 -0
- tau/inference/provider/oauth/__init__.py +35 -0
- tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
- tau/inference/provider/oauth/github_copilot.py +333 -0
- tau/inference/provider/oauth/google_antigravity.py +258 -0
- tau/inference/provider/oauth/openai_codex.py +309 -0
- tau/inference/provider/oauth/pkce.py +14 -0
- tau/inference/provider/oauth/types.py +46 -0
- tau/inference/provider/oauth/utils.py +154 -0
- tau/inference/provider/registry.py +141 -0
- tau/inference/provider/types.py +114 -0
- tau/inference/types.py +549 -0
- tau/inference/utils.py +219 -0
- tau/message/__init__.py +0 -0
- tau/message/types.py +482 -0
- tau/message/utils.py +178 -0
- tau/packages/__init__.py +11 -0
- tau/packages/manager.py +190 -0
- tau/packages/types.py +20 -0
- tau/packages/utils.py +67 -0
- tau/prompts/expand.py +58 -0
- tau/prompts/loader.py +69 -0
- tau/prompts/registry.py +45 -0
- tau/prompts/types.py +24 -0
- tau/rpc/__init__.py +8 -0
- tau/rpc/mode.py +783 -0
- tau/rpc/types.py +252 -0
- tau/runtime/service.py +759 -0
- tau/runtime/types.py +303 -0
- tau/session/branch_summarization.py +312 -0
- tau/session/compaction.py +646 -0
- tau/session/manager.py +652 -0
- tau/session/types.py +188 -0
- tau/session/utils.py +233 -0
- tau/settings/manager.py +1077 -0
- tau/settings/paths.py +150 -0
- tau/settings/storage.py +63 -0
- tau/settings/types.py +173 -0
- tau/settings/utils.py +25 -0
- tau/skills/loader.py +91 -0
- tau/skills/registry.py +70 -0
- tau/skills/types.py +25 -0
- tau/themes/loader.py +238 -0
- tau/themes/registry.py +108 -0
- tau/themes/types.py +19 -0
- tau/tool/__init__.py +3 -0
- tau/tool/registry.py +117 -0
- tau/tool/render.py +21 -0
- tau/tool/types.py +244 -0
- tau/trust/__init__.py +13 -0
- tau/trust/manager.py +80 -0
- tau/trust/types.py +14 -0
- tau/trust/utils.py +72 -0
- tau/tui/__init__.py +54 -0
- tau/tui/agent_hooks.py +346 -0
- tau/tui/ansi.py +330 -0
- tau/tui/app.py +540 -0
- tau/tui/autocomplete.py +33 -0
- tau/tui/capabilities.py +119 -0
- tau/tui/commands/__init__.py +3 -0
- tau/tui/commands/appearance.py +498 -0
- tau/tui/commands/auth.py +232 -0
- tau/tui/commands/context.py +38 -0
- tau/tui/commands/misc.py +82 -0
- tau/tui/commands/model.py +118 -0
- tau/tui/commands/session.py +464 -0
- tau/tui/component.py +268 -0
- tau/tui/components/__init__.py +0 -0
- tau/tui/components/autocomplete_manager.py +267 -0
- tau/tui/components/autocomplete_picker.py +143 -0
- tau/tui/components/box.py +90 -0
- tau/tui/components/command_palette.py +144 -0
- tau/tui/components/dynamic_border.py +19 -0
- tau/tui/components/file_picker.py +233 -0
- tau/tui/components/image.py +181 -0
- tau/tui/components/inline_selector.py +71 -0
- tau/tui/components/layout.py +1194 -0
- tau/tui/components/message_list.py +692 -0
- tau/tui/components/modal.py +97 -0
- tau/tui/components/model_palette.py +204 -0
- tau/tui/components/picker_overlay.py +174 -0
- tau/tui/components/prompt_overlay.py +236 -0
- tau/tui/components/resume_modal.py +372 -0
- tau/tui/components/select_list.py +222 -0
- tau/tui/components/settings_modal.py +274 -0
- tau/tui/components/settings_schema.py +203 -0
- tau/tui/components/spinner.py +119 -0
- tau/tui/components/text_input.py +396 -0
- tau/tui/components/text_prompt.py +82 -0
- tau/tui/components/tree_select_list.py +580 -0
- tau/tui/components/trust_screen.py +97 -0
- tau/tui/diff.py +114 -0
- tau/tui/fuzzy.py +99 -0
- tau/tui/input.py +496 -0
- tau/tui/input_handler.py +716 -0
- tau/tui/keybindings.py +87 -0
- tau/tui/markdown.py +286 -0
- tau/tui/message_renderers.py +31 -0
- tau/tui/overlay.py +326 -0
- tau/tui/renderer.py +378 -0
- tau/tui/terminal.py +499 -0
- tau/tui/theme.py +148 -0
- tau/tui/tui.py +544 -0
- tau/tui/ui_context.py +768 -0
- tau/tui/utils.py +20 -0
- tau/utils/__init__.py +0 -0
- tau/utils/http_proxy.py +221 -0
- tau/utils/image_processing.py +172 -0
- tau/utils/secrets.py +59 -0
- tau/utils/version_check.py +60 -0
- tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
- tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
- tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
- tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|