tynex 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.
- tynex/__init__.py +3 -0
- tynex/__main__.py +4 -0
- tynex/agent/__init__.py +1 -0
- tynex/agent/budget.py +127 -0
- tynex/agent/loop.py +356 -0
- tynex/agent/permissions.py +202 -0
- tynex/agent/settings.json +10 -0
- tynex/agent/skills/code-review/SKILL.md +504 -0
- tynex/agent/types.py +126 -0
- tynex/ai/__init__.py +1 -0
- tynex/ai/model_api.py +214 -0
- tynex/ai/models.py +396 -0
- tynex/ai/providers/__init__.py +38 -0
- tynex/ai/providers/anthropic.py +246 -0
- tynex/ai/providers/base.py +377 -0
- tynex/ai/providers/copilot.py +510 -0
- tynex/ai/providers/faux.py +116 -0
- tynex/ai/providers/google.py +181 -0
- tynex/ai/providers/openai.py +182 -0
- tynex/ai/types.py +380 -0
- tynex/cli.py +829 -0
- tynex/coding/__init__.py +1 -0
- tynex/coding/agent_session.py +933 -0
- tynex/coding/checkpoints.py +182 -0
- tynex/coding/mcp.py +222 -0
- tynex/coding/mcp_settings.py +115 -0
- tynex/coding/mcp_tools.py +155 -0
- tynex/coding/model_catalog.py +167 -0
- tynex/coding/permissions.py +80 -0
- tynex/coding/process.py +31 -0
- tynex/coding/session.py +631 -0
- tynex/coding/session_commands.py +412 -0
- tynex/coding/settings.py +252 -0
- tynex/coding/signin.py +539 -0
- tynex/coding/skills.py +119 -0
- tynex/coding/system_prompt.py +97 -0
- tynex/coding/tools/__init__.py +35 -0
- tynex/coding/tools/edit.py +261 -0
- tynex/coding/tools/path_utils.py +24 -0
- tynex/coding/tools/read.py +151 -0
- tynex/coding/tools/readonly.py +311 -0
- tynex/coding/tools/shell.py +294 -0
- tynex/coding/tools/truncate.py +161 -0
- tynex/coding/tools/websearch.py +83 -0
- tynex/coding/tools/write.py +57 -0
- tynex/coding/websearch/__init__.py +9 -0
- tynex/coding/websearch/providers.py +233 -0
- tynex/coding/websearch/readable.py +136 -0
- tynex/http.py +128 -0
- tynex/sdk/__init__.py +49 -0
- tynex/sdk/agent.py +336 -0
- tynex/sdk/tools.py +264 -0
- tynex/tui/__init__.py +1 -0
- tynex/tui/app.py +583 -0
- tynex/tui/prompt.py +246 -0
- tynex/tui/renderer.py +210 -0
- tynex/tui/select.py +59 -0
- tynex-0.1.0.dist-info/METADATA +466 -0
- tynex-0.1.0.dist-info/RECORD +63 -0
- tynex-0.1.0.dist-info/WHEEL +4 -0
- tynex-0.1.0.dist-info/entry_points.txt +2 -0
- tynex-0.1.0.dist-info/licenses/LICENSE +201 -0
- tynex-0.1.0.dist-info/licenses/NOTICE +4 -0
tynex/__init__.py
ADDED
tynex/__main__.py
ADDED
tynex/agent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent core: loop, types, and tool contracts."""
|
tynex/agent/budget.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Budgets: optional hard caps on total tokens or cost for one run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class BudgetLimits:
|
|
12
|
+
"""Optional caps for one run. ``None`` means unlimited."""
|
|
13
|
+
|
|
14
|
+
max_tokens: int | None = None # input + output + cache-read tokens
|
|
15
|
+
max_cost_usd: float | None = None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def empty(self) -> bool:
|
|
19
|
+
return self.max_tokens is None and self.max_cost_usd is None
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, Any]:
|
|
22
|
+
out: dict[str, Any] = {}
|
|
23
|
+
if self.max_tokens is not None:
|
|
24
|
+
out["maxTokens"] = self.max_tokens
|
|
25
|
+
if self.max_cost_usd is not None:
|
|
26
|
+
out["maxCostUsd"] = self.max_cost_usd
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def from_dict(data: dict[str, Any] | None) -> BudgetLimits | None:
|
|
31
|
+
"""Read the serialized form; unknown/legacy fields are ignored."""
|
|
32
|
+
if not isinstance(data, dict):
|
|
33
|
+
return None
|
|
34
|
+
kwargs: dict[str, Any] = {}
|
|
35
|
+
for key, field_name, cast in (("maxTokens", "max_tokens", int), ("maxCostUsd", "max_cost_usd", float)):
|
|
36
|
+
value = data.get(key)
|
|
37
|
+
if value is None or isinstance(value, bool):
|
|
38
|
+
continue
|
|
39
|
+
try:
|
|
40
|
+
kwargs[field_name] = cast(value)
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
continue
|
|
43
|
+
limits = BudgetLimits(**kwargs)
|
|
44
|
+
return None if limits.empty else limits
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_budget(spec: str) -> BudgetLimits:
|
|
48
|
+
"""Parse ``--budget``: ``cost=2.50``, ``tokens=250000``, or both comma-separated.
|
|
49
|
+
|
|
50
|
+
Raises ``ValueError`` with a user-facing message for anything else.
|
|
51
|
+
"""
|
|
52
|
+
limits = BudgetLimits()
|
|
53
|
+
for raw_item in spec.split(","):
|
|
54
|
+
item = raw_item.strip()
|
|
55
|
+
if not item:
|
|
56
|
+
continue
|
|
57
|
+
key, separator, raw_value = item.partition("=")
|
|
58
|
+
if not separator:
|
|
59
|
+
raise ValueError(f'expected cost=AMOUNT or tokens=COUNT, got "{item}"')
|
|
60
|
+
key = key.strip().lower()
|
|
61
|
+
value_text = raw_value.strip().lstrip("$")
|
|
62
|
+
if not value_text:
|
|
63
|
+
raise ValueError(f'missing value for "{key}"')
|
|
64
|
+
try:
|
|
65
|
+
value = float(value_text)
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
raise ValueError(f'invalid {key} value "{raw_value.strip()}"') from exc
|
|
68
|
+
if key == "cost":
|
|
69
|
+
if value <= 0:
|
|
70
|
+
raise ValueError("cost budget must be greater than zero")
|
|
71
|
+
limits.max_cost_usd = value
|
|
72
|
+
elif key == "tokens":
|
|
73
|
+
if value <= 0 or not value.is_integer():
|
|
74
|
+
raise ValueError("tokens budget must be a positive whole number")
|
|
75
|
+
limits.max_tokens = int(value)
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError(f'unknown budget field "{key}" (use cost or tokens)')
|
|
78
|
+
if limits.empty:
|
|
79
|
+
raise ValueError("empty budget — use cost=AMOUNT or tokens=COUNT")
|
|
80
|
+
return limits
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class BudgetState:
|
|
85
|
+
"""Mutable counters for one session."""
|
|
86
|
+
|
|
87
|
+
steps: int = 0
|
|
88
|
+
tool_calls: int = 0
|
|
89
|
+
tokens: int = 0
|
|
90
|
+
cost_usd: float = 0.0
|
|
91
|
+
started: float | None = None
|
|
92
|
+
|
|
93
|
+
def reset(self) -> None:
|
|
94
|
+
self.steps = 0
|
|
95
|
+
self.tool_calls = 0
|
|
96
|
+
self.tokens = 0
|
|
97
|
+
self.cost_usd = 0.0
|
|
98
|
+
self.started = None
|
|
99
|
+
|
|
100
|
+
def add_usage(self, usage: Any) -> None:
|
|
101
|
+
self.tokens += int(getattr(usage, "total_tokens", 0) or 0) + int(getattr(usage, "cache_read", 0) or 0)
|
|
102
|
+
self.cost_usd += float(getattr(usage, "cost", 0.0) or 0.0)
|
|
103
|
+
|
|
104
|
+
def exceeded(self, limits: BudgetLimits | None) -> str | None:
|
|
105
|
+
"""First exceeded limit as a human-readable reason, or ``None``."""
|
|
106
|
+
if limits is None:
|
|
107
|
+
return None
|
|
108
|
+
if limits.max_tokens is not None and self.tokens >= limits.max_tokens:
|
|
109
|
+
return f"token limit reached ({self.tokens:,}/{limits.max_tokens:,} tokens)"
|
|
110
|
+
if limits.max_cost_usd is not None and self.cost_usd >= limits.max_cost_usd:
|
|
111
|
+
return f"cost limit reached (${self.cost_usd:.4f}/${limits.max_cost_usd:.4f})"
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def begin(self) -> None:
|
|
115
|
+
if self.started is None:
|
|
116
|
+
self.started = time.monotonic()
|
|
117
|
+
|
|
118
|
+
def snapshot(self, limits: BudgetLimits | None = None) -> dict[str, Any]:
|
|
119
|
+
elapsed = (time.monotonic() - self.started) if self.started is not None else 0.0
|
|
120
|
+
return {
|
|
121
|
+
"steps": self.steps,
|
|
122
|
+
"toolCalls": self.tool_calls,
|
|
123
|
+
"tokens": self.tokens,
|
|
124
|
+
"cost": round(self.cost_usd, 6),
|
|
125
|
+
"seconds": round(elapsed, 1),
|
|
126
|
+
"limits": limits.to_dict() if limits else {},
|
|
127
|
+
}
|
tynex/agent/loop.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""The agent loop: streaming, tool execution, and turn coordination."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from tynex.agent.types import AgentContext, AgentLoopConfig, EventSink, PrepareNextTurnContext, ToolResult
|
|
7
|
+
from tynex.ai.providers.base import LlmContext, StreamOptions
|
|
8
|
+
from tynex.ai.types import AbortSignal, AssistantMessage, Message, TextContent, ToolCall, ToolResultMessage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _default_sink(event: dict[str, Any]) -> None:
|
|
12
|
+
"""Discard events when the caller did not supply a sink."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _emit_ready(message: Message, emit: EventSink) -> None:
|
|
16
|
+
emit({"type": "message_start", "message": message})
|
|
17
|
+
emit({"type": "message_end", "message": message})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_agent_loop(
|
|
21
|
+
prompts: list[Message],
|
|
22
|
+
context: AgentContext,
|
|
23
|
+
config: AgentLoopConfig,
|
|
24
|
+
signal: AbortSignal | None = None,
|
|
25
|
+
emit: EventSink | None = None,
|
|
26
|
+
) -> list[Message]:
|
|
27
|
+
"""Start a turn with one or more new messages (usually a single user message)."""
|
|
28
|
+
sink = emit or _default_sink
|
|
29
|
+
new_messages: list[Message] = list(prompts)
|
|
30
|
+
current = AgentContext(messages=[*context.messages, *prompts], tools=context.tools)
|
|
31
|
+
sink({"type": "agent_start"})
|
|
32
|
+
sink({"type": "turn_start"})
|
|
33
|
+
for message in prompts:
|
|
34
|
+
_emit_ready(message, sink)
|
|
35
|
+
final_context = _run_loop(current, new_messages, config, signal, sink)
|
|
36
|
+
context.messages = final_context.messages
|
|
37
|
+
context.tools = final_context.tools
|
|
38
|
+
return new_messages
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_loop(
|
|
42
|
+
initial_context: AgentContext,
|
|
43
|
+
new_messages: list[Message],
|
|
44
|
+
initial_config: AgentLoopConfig,
|
|
45
|
+
signal: AbortSignal | None,
|
|
46
|
+
sink: EventSink,
|
|
47
|
+
) -> AgentContext:
|
|
48
|
+
current = initial_context
|
|
49
|
+
config = initial_config
|
|
50
|
+
last_completed_turn: PrepareNextTurnContext | None = None
|
|
51
|
+
pending: list[Message] = list(config.get_steering_messages())
|
|
52
|
+
while True:
|
|
53
|
+
has_more_tool_calls = True
|
|
54
|
+
while has_more_tool_calls or pending:
|
|
55
|
+
if config.before_model_call is not None:
|
|
56
|
+
stop_reason = config.before_model_call()
|
|
57
|
+
if stop_reason:
|
|
58
|
+
sink({"type": "limit_reached", "reason": stop_reason})
|
|
59
|
+
sink({"type": "agent_end", "messages": new_messages})
|
|
60
|
+
return current
|
|
61
|
+
prepared: list[Message] = []
|
|
62
|
+
if last_completed_turn is not None:
|
|
63
|
+
result = config.prepare_next_turn(last_completed_turn) if config.prepare_next_turn else None
|
|
64
|
+
if result:
|
|
65
|
+
if result.context is not None:
|
|
66
|
+
current = result.context
|
|
67
|
+
prepared = list(result.messages or [])
|
|
68
|
+
if result.model is not None:
|
|
69
|
+
config.model = result.model
|
|
70
|
+
if result.thinking_level is not None:
|
|
71
|
+
config.thinking = result.thinking_level
|
|
72
|
+
if not pending:
|
|
73
|
+
pending = list(config.get_steering_messages())
|
|
74
|
+
sink({"type": "turn_start"})
|
|
75
|
+
for message in [*prepared, *pending]:
|
|
76
|
+
_emit_ready(message, sink)
|
|
77
|
+
current.messages.append(message)
|
|
78
|
+
new_messages.append(message)
|
|
79
|
+
pending = []
|
|
80
|
+
message = _stream_assistant_response(current, config, signal, sink)
|
|
81
|
+
new_messages.append(message)
|
|
82
|
+
if message.stop_reason in ("error", "aborted"):
|
|
83
|
+
last_completed_turn = PrepareNextTurnContext(message, [], current, new_messages)
|
|
84
|
+
sink({"type": "turn_end", "message": message, "toolResults": []})
|
|
85
|
+
sink({"type": "agent_end", "messages": new_messages})
|
|
86
|
+
return current
|
|
87
|
+
tool_calls = message.tool_calls()
|
|
88
|
+
tool_results: list[ToolResultMessage] = []
|
|
89
|
+
has_more_tool_calls = False
|
|
90
|
+
if tool_calls:
|
|
91
|
+
if message.stop_reason == "length":
|
|
92
|
+
batch = _fail_truncated_tool_calls(tool_calls, sink)
|
|
93
|
+
else:
|
|
94
|
+
batch = _execute_tool_calls(current, message, config, signal, sink)
|
|
95
|
+
tool_results = batch["messages"]
|
|
96
|
+
has_more_tool_calls = not batch["terminate"]
|
|
97
|
+
for result_message in tool_results:
|
|
98
|
+
current.messages.append(result_message)
|
|
99
|
+
new_messages.append(result_message)
|
|
100
|
+
last_completed_turn = PrepareNextTurnContext(message, list(tool_results), current, new_messages)
|
|
101
|
+
sink({"type": "turn_end", "message": message, "toolResults": tool_results})
|
|
102
|
+
pending = list(config.get_steering_messages())
|
|
103
|
+
follow_ups = list(config.get_follow_up_messages())
|
|
104
|
+
if follow_ups:
|
|
105
|
+
pending = follow_ups
|
|
106
|
+
continue
|
|
107
|
+
break
|
|
108
|
+
sink({"type": "agent_end", "messages": new_messages})
|
|
109
|
+
return current
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Assistant response streaming
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stream_assistant_response(
|
|
118
|
+
current: AgentContext, config: AgentLoopConfig, signal: AbortSignal | None, sink: EventSink
|
|
119
|
+
) -> AssistantMessage:
|
|
120
|
+
llm_messages = config.convert_to_llm(current.messages)
|
|
121
|
+
llm_context = LlmContext(messages=llm_messages, tools=[tool.declaration() for tool in config.tools])
|
|
122
|
+
options = StreamOptions(
|
|
123
|
+
api_key=config.api_key,
|
|
124
|
+
thinking=config.thinking,
|
|
125
|
+
temperature=config.temperature,
|
|
126
|
+
max_tokens=config.max_tokens,
|
|
127
|
+
signal=signal,
|
|
128
|
+
stealth=config.stealth,
|
|
129
|
+
)
|
|
130
|
+
partial: AssistantMessage | None = None
|
|
131
|
+
added_partial = False
|
|
132
|
+
final: AssistantMessage | None = None
|
|
133
|
+
for event in config.stream_fn(config.model, llm_context, options):
|
|
134
|
+
etype = event.get("type")
|
|
135
|
+
if etype == "start":
|
|
136
|
+
partial = event["partial"]
|
|
137
|
+
current.messages.append(partial)
|
|
138
|
+
added_partial = True
|
|
139
|
+
sink({"type": "message_start", "message": partial})
|
|
140
|
+
elif etype in (
|
|
141
|
+
"text_start",
|
|
142
|
+
"text_delta",
|
|
143
|
+
"text_end",
|
|
144
|
+
"thinking_start",
|
|
145
|
+
"thinking_delta",
|
|
146
|
+
"thinking_end",
|
|
147
|
+
"toolcall_start",
|
|
148
|
+
"toolcall_delta",
|
|
149
|
+
"toolcall_end",
|
|
150
|
+
):
|
|
151
|
+
partial = event["partial"]
|
|
152
|
+
if added_partial:
|
|
153
|
+
current.messages[-1] = partial
|
|
154
|
+
sink({"type": "message_update", "message": partial, "delta": event})
|
|
155
|
+
elif etype in ("done", "error"):
|
|
156
|
+
final = event["partial"]
|
|
157
|
+
if added_partial:
|
|
158
|
+
current.messages[-1] = final
|
|
159
|
+
else:
|
|
160
|
+
current.messages.append(final)
|
|
161
|
+
sink({"type": "message_start", "message": final})
|
|
162
|
+
sink({"type": "message_end", "message": final})
|
|
163
|
+
return final
|
|
164
|
+
final = partial or AssistantMessage(
|
|
165
|
+
content=[TextContent(text="")],
|
|
166
|
+
api=config.model.api,
|
|
167
|
+
provider=config.model.provider,
|
|
168
|
+
model=config.model.id,
|
|
169
|
+
stop_reason="error",
|
|
170
|
+
error_message="Provider produced no response",
|
|
171
|
+
)
|
|
172
|
+
if added_partial:
|
|
173
|
+
current.messages[-1] = final
|
|
174
|
+
else:
|
|
175
|
+
current.messages.append(final)
|
|
176
|
+
sink({"type": "message_end", "message": final})
|
|
177
|
+
return final
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Tool execution
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _fail_truncated_tool_calls(tool_calls: list[ToolCall], sink: EventSink) -> dict[str, Any]:
|
|
186
|
+
messages: list[ToolResultMessage] = []
|
|
187
|
+
for call in tool_calls:
|
|
188
|
+
sink({"type": "tool_execution_start", "toolCallId": call.id, "toolName": call.name, "args": call.arguments})
|
|
189
|
+
result = ToolResult.text(
|
|
190
|
+
f'Tool call "{call.name}" was not executed: the response hit the output token limit, '
|
|
191
|
+
"so its arguments may be truncated. Re-issue the tool call with complete arguments.",
|
|
192
|
+
is_error=True,
|
|
193
|
+
)
|
|
194
|
+
finalized = {"toolCall": call, "result": result, "isError": True, "durationMs": 0}
|
|
195
|
+
sink(
|
|
196
|
+
{
|
|
197
|
+
"type": "tool_execution_end",
|
|
198
|
+
"toolCallId": call.id,
|
|
199
|
+
"toolName": call.name,
|
|
200
|
+
"result": result,
|
|
201
|
+
"isError": True,
|
|
202
|
+
"durationMs": 0,
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
message = _tool_result_message(finalized)
|
|
206
|
+
_emit_ready(message, sink)
|
|
207
|
+
messages.append(message)
|
|
208
|
+
return {"messages": messages, "terminate": False}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _execute_tool_calls(
|
|
212
|
+
current: AgentContext,
|
|
213
|
+
assistant_message: AssistantMessage,
|
|
214
|
+
config: AgentLoopConfig,
|
|
215
|
+
signal: AbortSignal | None,
|
|
216
|
+
sink: EventSink,
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
tool_calls = assistant_message.tool_calls()
|
|
219
|
+
finalized_calls: list[dict[str, Any]] = []
|
|
220
|
+
messages: list[ToolResultMessage] = []
|
|
221
|
+
for call in tool_calls:
|
|
222
|
+
sink({"type": "tool_execution_start", "toolCallId": call.id, "toolName": call.name, "args": call.arguments})
|
|
223
|
+
prepared = _prepare_tool_call(current, assistant_message, call, config, signal)
|
|
224
|
+
if prepared["kind"] == "immediate":
|
|
225
|
+
finalized = {
|
|
226
|
+
"toolCall": call,
|
|
227
|
+
"result": prepared["result"],
|
|
228
|
+
"isError": prepared["isError"],
|
|
229
|
+
"durationMs": 0,
|
|
230
|
+
}
|
|
231
|
+
else:
|
|
232
|
+
finalized = _execute_prepared_tool_call(prepared, signal, _update_sink(call, sink))
|
|
233
|
+
sink(
|
|
234
|
+
{
|
|
235
|
+
"type": "tool_execution_end",
|
|
236
|
+
"toolCallId": call.id,
|
|
237
|
+
"toolName": call.name,
|
|
238
|
+
"result": finalized["result"],
|
|
239
|
+
"isError": finalized["isError"],
|
|
240
|
+
"durationMs": finalized.get("durationMs"),
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
message = _tool_result_message(finalized)
|
|
244
|
+
_emit_ready(message, sink)
|
|
245
|
+
finalized_calls.append(finalized)
|
|
246
|
+
messages.append(message)
|
|
247
|
+
if signal is not None and signal.aborted:
|
|
248
|
+
break
|
|
249
|
+
# A batch interrupted mid-way must still answer every tool call: providers
|
|
250
|
+
# reject a replayed tool_use block without a matching tool_result.
|
|
251
|
+
for call in tool_calls[len(messages) :]:
|
|
252
|
+
sink({"type": "tool_execution_start", "toolCallId": call.id, "toolName": call.name, "args": call.arguments})
|
|
253
|
+
result = ToolResult.text("Operation aborted", is_error=True)
|
|
254
|
+
finalized = {"toolCall": call, "result": result, "isError": True, "durationMs": 0}
|
|
255
|
+
sink(
|
|
256
|
+
{
|
|
257
|
+
"type": "tool_execution_end",
|
|
258
|
+
"toolCallId": call.id,
|
|
259
|
+
"toolName": call.name,
|
|
260
|
+
"result": result,
|
|
261
|
+
"isError": True,
|
|
262
|
+
"durationMs": 0,
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
message = _tool_result_message(finalized)
|
|
266
|
+
_emit_ready(message, sink)
|
|
267
|
+
finalized_calls.append(finalized)
|
|
268
|
+
messages.append(message)
|
|
269
|
+
terminate = bool(finalized_calls) and all(f["result"].terminate for f in finalized_calls)
|
|
270
|
+
return {"messages": messages, "terminate": terminate}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _prepare_tool_call(
|
|
274
|
+
current: AgentContext,
|
|
275
|
+
assistant_message: AssistantMessage,
|
|
276
|
+
call: ToolCall,
|
|
277
|
+
config: AgentLoopConfig,
|
|
278
|
+
signal: AbortSignal | None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
tool = next((t for t in config.tools if t.name == call.name), None)
|
|
281
|
+
if tool is None:
|
|
282
|
+
return {
|
|
283
|
+
"kind": "immediate",
|
|
284
|
+
"result": ToolResult.text(f"Tool {call.name} not found", is_error=True),
|
|
285
|
+
"isError": True,
|
|
286
|
+
}
|
|
287
|
+
try:
|
|
288
|
+
if tool.prepare_arguments:
|
|
289
|
+
prepared_args = tool.prepare_arguments(call.arguments)
|
|
290
|
+
if prepared_args is not call.arguments:
|
|
291
|
+
call = ToolCall(id=call.id, name=call.name, arguments=prepared_args)
|
|
292
|
+
if config.before_tool_call:
|
|
293
|
+
outcome = config.before_tool_call(
|
|
294
|
+
{
|
|
295
|
+
"assistantMessage": assistant_message,
|
|
296
|
+
"toolCall": call,
|
|
297
|
+
"tool": tool,
|
|
298
|
+
"args": call.arguments,
|
|
299
|
+
"context": current,
|
|
300
|
+
},
|
|
301
|
+
signal,
|
|
302
|
+
)
|
|
303
|
+
if outcome and outcome.get("block"):
|
|
304
|
+
result = ToolResult.text(outcome.get("reason") or "Tool execution was blocked", is_error=True)
|
|
305
|
+
if outcome.get("terminate"):
|
|
306
|
+
result.terminate = True
|
|
307
|
+
return {"kind": "immediate", "result": result, "isError": True}
|
|
308
|
+
if signal is not None and signal.aborted:
|
|
309
|
+
return {"kind": "immediate", "result": ToolResult.text("Operation aborted", is_error=True), "isError": True}
|
|
310
|
+
return {"kind": "prepared", "toolCall": call, "tool": tool, "args": call.arguments, "ctx": config.tool_context}
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
return {"kind": "immediate", "result": ToolResult.text(str(exc), is_error=True), "isError": True}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _execute_prepared_tool_call(prepared: dict[str, Any], signal: AbortSignal | None, on_update) -> dict[str, Any]:
|
|
316
|
+
started = time.monotonic()
|
|
317
|
+
try:
|
|
318
|
+
result = prepared["tool"].execute(
|
|
319
|
+
prepared["toolCall"].id, prepared["args"], signal, on_update, prepared.get("ctx")
|
|
320
|
+
)
|
|
321
|
+
is_error = result.is_error
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
result, is_error = ToolResult.text(str(exc), is_error=True), True
|
|
324
|
+
return {
|
|
325
|
+
"toolCall": prepared["toolCall"],
|
|
326
|
+
"result": result,
|
|
327
|
+
"isError": is_error,
|
|
328
|
+
"durationMs": int((time.monotonic() - started) * 1000),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _update_sink(call: ToolCall, sink: EventSink):
|
|
333
|
+
def on_update(partial: ToolResult) -> None:
|
|
334
|
+
sink(
|
|
335
|
+
{
|
|
336
|
+
"type": "tool_execution_update",
|
|
337
|
+
"toolCallId": call.id,
|
|
338
|
+
"toolName": call.name,
|
|
339
|
+
"args": call.arguments,
|
|
340
|
+
"partialResult": partial,
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return on_update
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _tool_result_message(finalized: dict[str, Any]) -> ToolResultMessage:
|
|
348
|
+
result: ToolResult = finalized["result"]
|
|
349
|
+
return ToolResultMessage(
|
|
350
|
+
tool_call_id=finalized["toolCall"].id,
|
|
351
|
+
tool_name=finalized["toolCall"].name,
|
|
352
|
+
content=list(result.content or []),
|
|
353
|
+
is_error=bool(finalized["isError"]),
|
|
354
|
+
details=result.details,
|
|
355
|
+
duration_ms=finalized.get("durationMs"),
|
|
356
|
+
)
|