python-agent-harness 1.5.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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""MCPManager: lifecycle + one-time tool discovery for MCP servers.
|
|
2
|
+
|
|
3
|
+
Synchronous facade over the async MCPClient wrapper: the harness's
|
|
4
|
+
agent loop is synchronous, so every SDK interaction runs on a DEDICATED
|
|
5
|
+
event-loop thread (``asyncio.run`` per call would bind SDK resources —
|
|
6
|
+
subprocess pipes, anyio memory streams — to a fresh loop each time and
|
|
7
|
+
break on the next call).
|
|
8
|
+
|
|
9
|
+
Lifecycle (mirrors the session lifecycle; see ``Session``)::
|
|
10
|
+
|
|
11
|
+
manager = MCPManager(config)
|
|
12
|
+
failures = manager.connect_all() # connect every configured server
|
|
13
|
+
specs = manager.discover_tools() # tools/list ONCE per session
|
|
14
|
+
... register MCPTool instances from the specs ...
|
|
15
|
+
manager.call_tool("github", "search", {...})
|
|
16
|
+
manager.close_all()
|
|
17
|
+
|
|
18
|
+
The agent loop never sees this class: MCP tools are ordinary registry
|
|
19
|
+
tools, and the manager only ever returns plain dicts or raises the
|
|
20
|
+
SDK/connection errors that the tool adapter turns into error strings.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable, Coroutine
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from .client import MCPClient, MCPUnavailableError
|
|
33
|
+
from .config import MCPConfig
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MCPCallCancelled(Exception):
|
|
37
|
+
"""Raised when an in-flight MCP call is cancelled (Ctrl-C).
|
|
38
|
+
|
|
39
|
+
The manager's cancel check is polled while waiting for the SDK
|
|
40
|
+
call; when it fires the underlying future is cancelled and this
|
|
41
|
+
exception propagates, so a hung server call can never wedge the
|
|
42
|
+
agent-loop thread forever.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MCPToolSpec:
|
|
47
|
+
"""A tool advertised by an MCP server (one tools/list entry)."""
|
|
48
|
+
|
|
49
|
+
__slots__ = ("server", "name", "description", "input_schema")
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
server: str,
|
|
54
|
+
name: str,
|
|
55
|
+
description: str,
|
|
56
|
+
input_schema: dict[str, Any],
|
|
57
|
+
) -> None:
|
|
58
|
+
self.server = server
|
|
59
|
+
self.name = name
|
|
60
|
+
self.description = description
|
|
61
|
+
self.input_schema = input_schema
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _LoopThread:
|
|
65
|
+
"""A background thread running a persistent asyncio event loop.
|
|
66
|
+
|
|
67
|
+
All SDK interactions for one MCPManager run on this loop, so
|
|
68
|
+
transport resources stay bound to a single loop for their whole
|
|
69
|
+
lifetime. ``run`` blocks the caller until the coroutine completes
|
|
70
|
+
(or TIMEOUT elapses) and re-raises its exception here.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._loop = asyncio.new_event_loop()
|
|
75
|
+
self._thread = threading.Thread(target=self._run, daemon=True, name="mcp-loop")
|
|
76
|
+
self._thread.start()
|
|
77
|
+
|
|
78
|
+
def _run(self) -> None:
|
|
79
|
+
asyncio.set_event_loop(self._loop)
|
|
80
|
+
self._loop.run_forever()
|
|
81
|
+
|
|
82
|
+
def run(
|
|
83
|
+
self,
|
|
84
|
+
coro: Coroutine[Any, Any, Any],
|
|
85
|
+
timeout: float | None = None,
|
|
86
|
+
cancel_check: Callable[[], bool] | None = None,
|
|
87
|
+
) -> Any:
|
|
88
|
+
"""Run CORO on the loop thread; raise its exception here.
|
|
89
|
+
|
|
90
|
+
Waits in short slices instead of one blocking
|
|
91
|
+
``future.result(timeout=...)``: a ``cancel_check`` is polled
|
|
92
|
+
between slices, so Ctrl-C unblocks a hung SDK call (the future
|
|
93
|
+
is cancelled and :class:`MCPCallCancelled` raised) instead of
|
|
94
|
+
wedging the caller forever. ``timeout`` is a wall-clock
|
|
95
|
+
deadline; on expiry the future is cancelled and TimeoutError
|
|
96
|
+
raised. A TimeoutError raised BY the coroutine itself (SDK
|
|
97
|
+
read timeout) propagates untouched — ``future.exception`` keeps
|
|
98
|
+
it distinct from the poll timeout.
|
|
99
|
+
"""
|
|
100
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
101
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
exc = future.exception(timeout=0.1)
|
|
105
|
+
except TimeoutError:
|
|
106
|
+
# future still running: enforce deadline / cancellation
|
|
107
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
108
|
+
future.cancel()
|
|
109
|
+
raise TimeoutError(f"MCP call timed out after {timeout}s") from None
|
|
110
|
+
if cancel_check is not None and cancel_check():
|
|
111
|
+
future.cancel()
|
|
112
|
+
raise MCPCallCancelled("MCP call cancelled") from None
|
|
113
|
+
continue
|
|
114
|
+
if exc is not None:
|
|
115
|
+
raise exc
|
|
116
|
+
return future.result()
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
120
|
+
self._thread.join(timeout=5)
|
|
121
|
+
# close the loop object itself (it is stopped and idle by now),
|
|
122
|
+
# or the GC reports ResourceWarning for every manager lifetime
|
|
123
|
+
with contextlib.suppress(Exception): # best effort teardown
|
|
124
|
+
self._loop.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MCPManager:
|
|
128
|
+
"""Owns the MCP server connections for one session."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, config: MCPConfig | None = None) -> None:
|
|
131
|
+
self.config = config if config is not None else MCPConfig()
|
|
132
|
+
self._loop: _LoopThread | None = None
|
|
133
|
+
self._clients: dict[str, MCPClient] = {}
|
|
134
|
+
self._tools: dict[str, MCPToolSpec] = {} # "server__tool" -> spec
|
|
135
|
+
self._discovered: set[str] = set()
|
|
136
|
+
# (server, error) pairs from the last connect_all / discovery
|
|
137
|
+
self.errors: list[tuple[str, str]] = []
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# plumbing
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
def _ensure_loop(self) -> _LoopThread:
|
|
143
|
+
if self._loop is None:
|
|
144
|
+
self._loop = _LoopThread()
|
|
145
|
+
return self._loop
|
|
146
|
+
|
|
147
|
+
def _call(
|
|
148
|
+
self,
|
|
149
|
+
coro: Coroutine[Any, Any, Any],
|
|
150
|
+
timeout: float | None = None,
|
|
151
|
+
cancel_check: Callable[[], bool] | None = None,
|
|
152
|
+
) -> Any:
|
|
153
|
+
return self._ensure_loop().run(coro, timeout=timeout, cancel_check=cancel_check)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def connected(self) -> list[str]:
|
|
157
|
+
"""Names of the currently-connected servers (sorted)."""
|
|
158
|
+
return sorted(self._clients)
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# lifecycle
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
def connect_all(self) -> list[tuple[str, str]]:
|
|
164
|
+
"""Connect every configured server; return ``[(name, error)]`` failures.
|
|
165
|
+
|
|
166
|
+
A failing server never takes the session down: it is skipped
|
|
167
|
+
(with its error recorded) and the rest keep working. Idempotent:
|
|
168
|
+
already-connected servers are left alone.
|
|
169
|
+
"""
|
|
170
|
+
failures: list[tuple[str, str]] = []
|
|
171
|
+
for name, server_config in self.config.servers.items():
|
|
172
|
+
if not server_config.enabled:
|
|
173
|
+
continue
|
|
174
|
+
error = self._connect_one(name)
|
|
175
|
+
if error is not None:
|
|
176
|
+
failures.append((name, error))
|
|
177
|
+
self.errors = list(failures)
|
|
178
|
+
return failures
|
|
179
|
+
|
|
180
|
+
def _connect_one(self, name: str) -> str | None:
|
|
181
|
+
if name in self._clients:
|
|
182
|
+
return None
|
|
183
|
+
client = MCPClient(self.config.servers[name])
|
|
184
|
+
try:
|
|
185
|
+
self._call(client.connect(), timeout=self.config.servers[name].timeout)
|
|
186
|
+
except MCPUnavailableError as e:
|
|
187
|
+
error = str(e)
|
|
188
|
+
except Exception as e: # noqa: BLE001 - per-server failure, never fatal
|
|
189
|
+
error = f"MCP server {name!r} failed to connect: {e}"
|
|
190
|
+
else:
|
|
191
|
+
self._clients[name] = client
|
|
192
|
+
return None
|
|
193
|
+
# A failed connect may still have spawned the server process /
|
|
194
|
+
# opened an HTTP session before failing (e.g. timeout mid-
|
|
195
|
+
# handshake) — close the client so nothing leaks. Best effort:
|
|
196
|
+
# teardown noise must never mask the original error.
|
|
197
|
+
with contextlib.suppress(Exception): # teardown noise
|
|
198
|
+
self._call(client.close(), timeout=self.config.servers[name].timeout)
|
|
199
|
+
return error
|
|
200
|
+
|
|
201
|
+
def disconnect(self, name: str) -> None:
|
|
202
|
+
"""Disconnect one server and drop its discovered tools."""
|
|
203
|
+
import contextlib
|
|
204
|
+
|
|
205
|
+
client = self._clients.pop(name, None)
|
|
206
|
+
if client is None:
|
|
207
|
+
return
|
|
208
|
+
with contextlib.suppress(Exception): # teardown noise
|
|
209
|
+
self._call(client.close())
|
|
210
|
+
self._discovered.discard(name)
|
|
211
|
+
for key in [k for k in self._tools if k.startswith(name + "__")]:
|
|
212
|
+
del self._tools[key]
|
|
213
|
+
|
|
214
|
+
def close_all(self) -> None:
|
|
215
|
+
"""Disconnect every server and stop the event-loop thread."""
|
|
216
|
+
for name in list(self._clients):
|
|
217
|
+
self.disconnect(name)
|
|
218
|
+
self._tools.clear()
|
|
219
|
+
self._discovered.clear()
|
|
220
|
+
if self._loop is not None:
|
|
221
|
+
self._loop.close()
|
|
222
|
+
self._loop = None
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# tool discovery (once per session, not per turn)
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
def discover_tools(self) -> list[MCPToolSpec]:
|
|
228
|
+
"""tools/list each connected server; the result is cached.
|
|
229
|
+
|
|
230
|
+
Called once at session start; a refresh requires reconnecting
|
|
231
|
+
the server (``disconnect`` + ``connect_all``). Returns the
|
|
232
|
+
specs discovered by THIS call (newly discovered only), so a
|
|
233
|
+
caller can register exactly what changed.
|
|
234
|
+
"""
|
|
235
|
+
specs: list[MCPToolSpec] = []
|
|
236
|
+
for name, client in self._clients.items():
|
|
237
|
+
if name in self._discovered:
|
|
238
|
+
continue
|
|
239
|
+
self._discovered.add(name)
|
|
240
|
+
try:
|
|
241
|
+
raw_tools = self._call(
|
|
242
|
+
client.list_tools(), timeout=self.config.servers[name].timeout
|
|
243
|
+
)
|
|
244
|
+
except Exception as e: # noqa: BLE001 - per-server failure, never fatal
|
|
245
|
+
self.errors.append((name, f"MCP server {name!r} tool discovery failed: {e}"))
|
|
246
|
+
continue
|
|
247
|
+
for t in raw_tools:
|
|
248
|
+
spec = MCPToolSpec(
|
|
249
|
+
server=name,
|
|
250
|
+
name=t["name"],
|
|
251
|
+
description=t.get("description") or "",
|
|
252
|
+
input_schema=t.get("input_schema") or {},
|
|
253
|
+
)
|
|
254
|
+
self._tools[f"{name}__{t['name']}"] = spec
|
|
255
|
+
specs.append(spec)
|
|
256
|
+
return specs
|
|
257
|
+
|
|
258
|
+
def tool_specs(self) -> list[MCPToolSpec]:
|
|
259
|
+
"""All discovered specs, for building harness tools."""
|
|
260
|
+
return list(self._tools.values())
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# tool calls
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
def call_tool(
|
|
266
|
+
self,
|
|
267
|
+
server: str,
|
|
268
|
+
tool: str,
|
|
269
|
+
arguments: dict[str, Any],
|
|
270
|
+
timeout: float | None = None,
|
|
271
|
+
cancel_check: Callable[[], bool] | None = None,
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""Call TOOL on SERVER; returns the plain result dict from
|
|
274
|
+
:meth:`MCPClient.call_tool`.
|
|
275
|
+
|
|
276
|
+
Raises on connection/protocol errors (the tool adapter turns
|
|
277
|
+
them into ``Error: ...`` strings); server-reported failures are
|
|
278
|
+
flagged with ``is_error`` in the result dict, not raised.
|
|
279
|
+
``cancel_check`` (when given) is polled while waiting, so a
|
|
280
|
+
Ctrl-C unblocks a hung call (see ``_LoopThread.run``).
|
|
281
|
+
"""
|
|
282
|
+
client = self._clients.get(server)
|
|
283
|
+
if client is None:
|
|
284
|
+
raise ConnectionError(f"MCP server {server!r} is not connected")
|
|
285
|
+
effective_timeout = client.config.timeout if timeout is None else timeout
|
|
286
|
+
return self._call(
|
|
287
|
+
client.call_tool(tool, arguments),
|
|
288
|
+
timeout=effective_timeout,
|
|
289
|
+
cancel_check=cancel_check,
|
|
290
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Data model classes for the agent harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentMode(enum.Enum):
|
|
12
|
+
BUILD = "build"
|
|
13
|
+
PLAN = "plan"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ToolCall:
|
|
18
|
+
"""A tool invocation requested by the model."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
arguments: dict[str, Any] | str
|
|
23
|
+
result: str | None = None
|
|
24
|
+
diff: str | None = None # unified diff for Edit/Write, for TUI rendering
|
|
25
|
+
elapsed: float | None = None # execution wall-time in seconds (TUI display)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Message:
|
|
30
|
+
"""One conversation message in OpenAI-compatible format.
|
|
31
|
+
|
|
32
|
+
``role`` is one of system/user/assistant/tool.
|
|
33
|
+
``content`` may be a str or a list of parts (multimodal).
|
|
34
|
+
``tool_calls`` carries requested tool invocations on assistant messages.
|
|
35
|
+
``tool_call_id`` links a tool message to its assistant tool call.
|
|
36
|
+
``reasoning`` holds reasoning content if the backend reports it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
role: str
|
|
40
|
+
content: str | list[Any] | None = None
|
|
41
|
+
tool_calls: list[ToolCall] | None = None
|
|
42
|
+
tool_call_id: str | None = None
|
|
43
|
+
reasoning: str | None = None
|
|
44
|
+
name: str | None = None
|
|
45
|
+
injected: bool = False # harness-injected (nudge/plan/build-switch), not user input
|
|
46
|
+
|
|
47
|
+
def to_api(self) -> dict[str, Any]:
|
|
48
|
+
d: dict[str, Any] = {"role": self.role}
|
|
49
|
+
if self.content is not None:
|
|
50
|
+
d["content"] = self._api_content()
|
|
51
|
+
if self.tool_calls:
|
|
52
|
+
d["tool_calls"] = [
|
|
53
|
+
{
|
|
54
|
+
"id": tc.id,
|
|
55
|
+
"type": "function",
|
|
56
|
+
"function": {
|
|
57
|
+
"name": tc.name,
|
|
58
|
+
"arguments": tc.arguments
|
|
59
|
+
if isinstance(tc.arguments, str)
|
|
60
|
+
else json.dumps(tc.arguments, ensure_ascii=False),
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
for tc in self.tool_calls
|
|
64
|
+
]
|
|
65
|
+
if self.tool_call_id:
|
|
66
|
+
d["tool_call_id"] = self.tool_call_id
|
|
67
|
+
if self.name:
|
|
68
|
+
d["name"] = self.name
|
|
69
|
+
return d
|
|
70
|
+
|
|
71
|
+
def _api_content(self) -> str | list[Any] | None:
|
|
72
|
+
"""Content as sent over the wire, with the reasoning preamble removed.
|
|
73
|
+
|
|
74
|
+
The client merges streamed ``reasoning_content`` ahead of the
|
|
75
|
+
answer into ``content`` (so the live stream and stored history
|
|
76
|
+
show the model's thinking). That reasoning is bookkeeping for
|
|
77
|
+
the current turn only — re-sending it on later turns just
|
|
78
|
+
inflates the context (and skews token estimation) and can
|
|
79
|
+
confuse the model, so it is stripped here at the API boundary.
|
|
80
|
+
The stored ``content`` is left untouched (the TUI collapses the
|
|
81
|
+
reasoning for display via its own helper).
|
|
82
|
+
"""
|
|
83
|
+
content = self.content
|
|
84
|
+
if self.reasoning and isinstance(content, str):
|
|
85
|
+
if content.startswith(self.reasoning):
|
|
86
|
+
return content[len(self.reasoning) :].lstrip("\n")
|
|
87
|
+
stripped = content.lstrip()
|
|
88
|
+
if stripped.startswith(self.reasoning):
|
|
89
|
+
return stripped[len(self.reasoning) :].lstrip("\n")
|
|
90
|
+
return content
|
|
91
|
+
|
|
92
|
+
def text(self) -> str:
|
|
93
|
+
"""Plain text of the message; empty when no text parts exist."""
|
|
94
|
+
if isinstance(self.content, str):
|
|
95
|
+
return self.content
|
|
96
|
+
if isinstance(self.content, list):
|
|
97
|
+
parts: list[str] = []
|
|
98
|
+
for p in self.content:
|
|
99
|
+
if isinstance(p, str):
|
|
100
|
+
parts.append(p)
|
|
101
|
+
elif isinstance(p, dict):
|
|
102
|
+
if isinstance(p.get("text"), str):
|
|
103
|
+
parts.append(p["text"])
|
|
104
|
+
elif isinstance(p.get("thinking"), str):
|
|
105
|
+
parts.append(p["thinking"])
|
|
106
|
+
return "".join(parts)
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
def text_without_reasoning(self) -> str:
|
|
110
|
+
"""Plain text with the reasoning preamble stripped.
|
|
111
|
+
|
|
112
|
+
Use this for one-shot results (compaction, summary, title) where
|
|
113
|
+
the reasoning chain should not leak into the stored output.
|
|
114
|
+
"""
|
|
115
|
+
t = self.text()
|
|
116
|
+
if self.reasoning and t:
|
|
117
|
+
if t.startswith(self.reasoning):
|
|
118
|
+
return t[len(self.reasoning) :].lstrip("\n")
|
|
119
|
+
stripped = t.lstrip()
|
|
120
|
+
if stripped.startswith(self.reasoning):
|
|
121
|
+
return stripped[len(self.reasoning) :].lstrip("\n")
|
|
122
|
+
# Fallback: remove the reasoning anywhere in the text
|
|
123
|
+
return t.replace(self.reasoning, "").strip()
|
|
124
|
+
return t
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class ToolSpec:
|
|
129
|
+
"""A tool exposed to the model (JSON schema)."""
|
|
130
|
+
|
|
131
|
+
name: str
|
|
132
|
+
description: str
|
|
133
|
+
parameters: dict[str, Any] = field(default_factory=dict)
|
|
134
|
+
|
|
135
|
+
def to_api(self) -> dict[str, Any]:
|
|
136
|
+
return {
|
|
137
|
+
"type": "function",
|
|
138
|
+
"function": {
|
|
139
|
+
"name": self.name,
|
|
140
|
+
"description": self.description,
|
|
141
|
+
"parameters": self.parameters,
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class Usage:
|
|
148
|
+
input_tokens: int = 0
|
|
149
|
+
output_tokens: int = 0
|