stcode 0.0.1a0__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.
- stcode/__init__.py +10 -0
- stcode/cli/__init__.py +9 -0
- stcode/cli/bootstrap.py +70 -0
- stcode/cli/labels.py +621 -0
- stcode/cli/logic/__init__.py +0 -0
- stcode/cli/logic/commands.py +36 -0
- stcode/cli/logic/completion.py +43 -0
- stcode/cli/logic/connection.py +46 -0
- stcode/cli/logic/requests.py +18 -0
- stcode/cli/logic/sessions.py +67 -0
- stcode/cli/logic/settings.py +35 -0
- stcode/cli/logic/state.py +43 -0
- stcode/cli/logic/workspace.py +17 -0
- stcode/cli/main.py +290 -0
- stcode/cli/models.py +61 -0
- stcode/cli/services/__init__.py +0 -0
- stcode/cli/services/client.py +393 -0
- stcode/cli/services/launcher.py +159 -0
- stcode/cli/services/paths.py +18 -0
- stcode/cli/services/preferences.py +107 -0
- stcode/cli/ui/__init__.py +0 -0
- stcode/cli/ui/app.py +136 -0
- stcode/cli/ui/app.tcss +3 -0
- stcode/cli/ui/components/__init__.py +0 -0
- stcode/cli/ui/components/banner.py +78 -0
- stcode/cli/ui/components/cards.py +229 -0
- stcode/cli/ui/components/prompt.py +225 -0
- stcode/cli/ui/components/status.py +95 -0
- stcode/cli/ui/components/transcript/__init__.py +51 -0
- stcode/cli/ui/components/transcript/entries.py +232 -0
- stcode/cli/ui/components/transcript/formatting.py +188 -0
- stcode/cli/ui/components/transcript/view.py +101 -0
- stcode/cli/ui/screens/__init__.py +0 -0
- stcode/cli/ui/screens/chat/__init__.py +0 -0
- stcode/cli/ui/screens/chat/chat.tcss +12 -0
- stcode/cli/ui/screens/chat/presenter.py +187 -0
- stcode/cli/ui/screens/chat/screen.py +682 -0
- stcode/cli/ui/screens/connect.py +126 -0
- stcode/cli/ui/screens/connect.tcss +49 -0
- stcode/cli/ui/screens/dialog.tcss +40 -0
- stcode/cli/ui/screens/sessions.py +105 -0
- stcode/cli/ui/screens/settings.py +137 -0
- stcode/cli/ui/screens/settings.tcss +69 -0
- stcode/cli/ui/screens/trust.py +50 -0
- stcode/cli/ui/theme.py +221 -0
- stcode/core/__main__.py +7 -0
- stcode/core/agent/__init__.py +41 -0
- stcode/core/agent/agent.py +651 -0
- stcode/core/agent/events.py +97 -0
- stcode/core/agent/supervisor.py +226 -0
- stcode/core/agent/task.py +120 -0
- stcode/core/common/__init__.py +21 -0
- stcode/core/common/compat.py +27 -0
- stcode/core/common/ids.py +43 -0
- stcode/core/common/tools.py +50 -0
- stcode/core/common/trace.py +406 -0
- stcode/core/common/truncate.py +76 -0
- stcode/core/configs.py +490 -0
- stcode/core/daemon/__init__.py +32 -0
- stcode/core/daemon/autonomy.py +71 -0
- stcode/core/daemon/main.py +126 -0
- stcode/core/daemon/protocol.py +499 -0
- stcode/core/daemon/runner.py +359 -0
- stcode/core/daemon/server.py +682 -0
- stcode/core/daemon/workspace.py +88 -0
- stcode/core/harness/__init__.py +81 -0
- stcode/core/harness/approvals.py +85 -0
- stcode/core/harness/context.py +260 -0
- stcode/core/harness/errors.py +40 -0
- stcode/core/harness/harness.py +417 -0
- stcode/core/harness/mcp.py +530 -0
- stcode/core/harness/outputs.py +99 -0
- stcode/core/harness/prompts/__init__.py +138 -0
- stcode/core/harness/prompts/execute.py +51 -0
- stcode/core/harness/prompts/plan.py +57 -0
- stcode/core/harness/prompts/sections.py +238 -0
- stcode/core/harness/skills/__init__.py +30 -0
- stcode/core/harness/skills/loader.py +247 -0
- stcode/core/harness/tools/__init__.py +111 -0
- stcode/core/harness/tools/base.py +640 -0
- stcode/core/harness/tools/files.py +276 -0
- stcode/core/harness/tools/interact.py +72 -0
- stcode/core/harness/tools/registry.py +113 -0
- stcode/core/harness/tools/repl.py +97 -0
- stcode/core/harness/tools/schema.py +186 -0
- stcode/core/harness/tools/search.py +356 -0
- stcode/core/harness/tools/shell.py +372 -0
- stcode/core/harness/tools/skill.py +66 -0
- stcode/core/harness/tools/todo.py +62 -0
- stcode/core/harness/tools/web.py +163 -0
- stcode/core/providers/__init__.py +31 -0
- stcode/core/providers/anthropic_claude.py +243 -0
- stcode/core/providers/base.py +73 -0
- stcode/core/providers/gateway.py +267 -0
- stcode/core/providers/google_gemini.py +225 -0
- stcode/core/providers/openai_gpt.py +250 -0
- stcode/core/providers/registry.py +46 -0
- stcode/core/providers/types.py +148 -0
- stcode/core/repl/__init__.py +19 -0
- stcode/core/repl/_worker.py +179 -0
- stcode/core/repl/client.py +374 -0
- stcode/core/session/__init__.py +39 -0
- stcode/core/session/session.py +493 -0
- stcode/core/team/__init__.py +38 -0
- stcode/core/team/mailbox.py +249 -0
- stcode/core/team/tools.py +145 -0
- stcode-0.0.1a0.dist-info/METADATA +229 -0
- stcode-0.0.1a0.dist-info/RECORD +111 -0
- stcode-0.0.1a0.dist-info/WHEEL +4 -0
- stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
- stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`@tool` — turn a Python function into a tool.
|
|
3
|
+
|
|
4
|
+
@tool(permission=ToolPermission.WRITE)
|
|
5
|
+
async def write(path: str, content: str, runtime: Runtime[HarnessContext]) -> str:
|
|
6
|
+
'''Write `content` to `path`, creating parent directories as needed.'''
|
|
7
|
+
|
|
8
|
+
write.to_tool_definition() # -> the schema a provider sees
|
|
9
|
+
await write.invoke({"path": ...}, runtime=rt) # gated, validated, never raises
|
|
10
|
+
await write(path, content, runtime=rt) # plain call, raises like Python
|
|
11
|
+
|
|
12
|
+
Three rules:
|
|
13
|
+
|
|
14
|
+
* **The signature is the schema, the docstring is the description.** Both are read off
|
|
15
|
+
the function (`schema.py`), so a tool's interface cannot drift from its code.
|
|
16
|
+
* **`Runtime` is the parameter the model cannot see.** cwd, approval mode, progress
|
|
17
|
+
callbacks — injected at call time, filtered out of the schema. A model that can see
|
|
18
|
+
`approval_mode` is a model that will try to set it.
|
|
19
|
+
* **A tool never decides its own permissions.** It declares a `ToolPermission` once;
|
|
20
|
+
`approvals.py` decides what that means under the current mode.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextvars
|
|
27
|
+
import inspect
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import (
|
|
34
|
+
Any,
|
|
35
|
+
Awaitable,
|
|
36
|
+
Callable,
|
|
37
|
+
Generic,
|
|
38
|
+
MutableMapping,
|
|
39
|
+
TypeVar,
|
|
40
|
+
get_args,
|
|
41
|
+
get_origin,
|
|
42
|
+
get_type_hints,
|
|
43
|
+
overload,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
47
|
+
|
|
48
|
+
from stcode.core.common.tools import ToolDefinition, ToolResult
|
|
49
|
+
from stcode.core.harness.approvals import (
|
|
50
|
+
DEFAULT_APPROVAL_MODE,
|
|
51
|
+
ApprovalMode,
|
|
52
|
+
ToolPermission,
|
|
53
|
+
is_forbidden,
|
|
54
|
+
requires_approval,
|
|
55
|
+
)
|
|
56
|
+
from stcode.core.harness.errors import (
|
|
57
|
+
ToolCancelled,
|
|
58
|
+
ToolDenied,
|
|
59
|
+
ToolError,
|
|
60
|
+
ToolForbidden,
|
|
61
|
+
ToolTimeout,
|
|
62
|
+
)
|
|
63
|
+
from stcode.core.harness.tools.schema import build_params_model, json_schema_for, split_docstring
|
|
64
|
+
from stcode.core.common.truncate import (
|
|
65
|
+
DEFAULT_VIEW_LIMIT,
|
|
66
|
+
NARROW_REQUEST_HINT,
|
|
67
|
+
elide,
|
|
68
|
+
tool_out_hint,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
CtxT = TypeVar("CtxT")
|
|
72
|
+
|
|
73
|
+
DEFAULT_MAX_OUTPUT = DEFAULT_VIEW_LIMIT
|
|
74
|
+
"""Chars of a result the model sees. Bulk-retrieval tools raise it explicitly."""
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger("stcode.harness.tools")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---- the human in the loop -----------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ApprovalRequest(BaseModel):
|
|
83
|
+
"""What the UI shows when a tool needs a human's yes.
|
|
84
|
+
|
|
85
|
+
Carries validated arguments, not a rendered sentence — the wording is
|
|
86
|
+
`cli/labels.py`'s job, not the engine's.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
tool_name: str
|
|
90
|
+
permission: ToolPermission
|
|
91
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
92
|
+
agent_name: str = "main"
|
|
93
|
+
execution_id: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Question(BaseModel):
|
|
97
|
+
"""A question put to the user mid-turn by `ask_user_question`."""
|
|
98
|
+
|
|
99
|
+
question: str
|
|
100
|
+
header: str = ""
|
|
101
|
+
options: list[str] = Field(default_factory=list)
|
|
102
|
+
multi_select: bool = False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
ProgressFn = Callable[[str], Any]
|
|
106
|
+
"""`(text) -> None | Awaitable[None]`. Streams one line of progress to the UI."""
|
|
107
|
+
|
|
108
|
+
ApprovalFn = Callable[[ApprovalRequest], Awaitable[bool]]
|
|
109
|
+
AskFn = Callable[[Question], Awaitable[str]]
|
|
110
|
+
|
|
111
|
+
EventFn = Callable[[str, Any], None]
|
|
112
|
+
"""`(agent_name, event) -> None`. A sub-agent's event, for a host that renders one.
|
|
113
|
+
|
|
114
|
+
Synchronous and fire-and-forget, unlike the three above. It is a render hint: a tool
|
|
115
|
+
must never be slowed down — or failed — by a client that has stopped reading. Left
|
|
116
|
+
unset it is a no-op, which is the whole behaviour of an agent nobody is watching.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---- runtime -------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Runtime(Generic[CtxT]):
|
|
125
|
+
"""Everything a tool needs that is not an argument. Injected, never advertised.
|
|
126
|
+
|
|
127
|
+
Four groups: `context` (the agent's own state, typed by the caller), identity
|
|
128
|
+
(who is calling — the trajectory log groups by this), this call (ids, attempt
|
|
129
|
+
count), and capabilities (approval mode, cancellation, the three callbacks).
|
|
130
|
+
Callbacks are optional; a tool that needs a missing one fails clearly.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
context: CtxT
|
|
134
|
+
|
|
135
|
+
session_id: str = ""
|
|
136
|
+
agent_name: str = "main"
|
|
137
|
+
agent_id: str = ""
|
|
138
|
+
depth: int = 0
|
|
139
|
+
|
|
140
|
+
tool_name: str = ""
|
|
141
|
+
tool_call_id: str = ""
|
|
142
|
+
execution_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
143
|
+
attempt: int = 1
|
|
144
|
+
started_at: float = field(default_factory=time.monotonic)
|
|
145
|
+
|
|
146
|
+
approval_mode: ApprovalMode = DEFAULT_APPROVAL_MODE
|
|
147
|
+
deadline: float | None = None
|
|
148
|
+
cancel: asyncio.Event = field(default_factory=asyncio.Event)
|
|
149
|
+
|
|
150
|
+
outputs: MutableMapping[str, Any] = field(default_factory=dict)
|
|
151
|
+
"""In-process mirror of the REPL's `tool_out`. Anything elided from a result is
|
|
152
|
+
here under its `output_id`; `Harness.invoke` pushes it across."""
|
|
153
|
+
|
|
154
|
+
outputs_reachable: bool = False
|
|
155
|
+
"""Whether a REPL is attached, so `tool_out` is somewhere the model can read.
|
|
156
|
+
True and an elision may name `tool_out["..."]`; False and it may only say "ask for
|
|
157
|
+
a narrower range". Set by `Harness.runtime()`, never guessed here."""
|
|
158
|
+
|
|
159
|
+
on_progress: ProgressFn | None = None
|
|
160
|
+
on_approval: ApprovalFn | None = None
|
|
161
|
+
on_ask: AskFn | None = None
|
|
162
|
+
on_event: EventFn | None = None
|
|
163
|
+
|
|
164
|
+
logger: logging.Logger = logger
|
|
165
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
166
|
+
|
|
167
|
+
# ---- derived ----
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def remaining(self) -> float | None:
|
|
171
|
+
"""Seconds left before `deadline`, or None when the call is untimed."""
|
|
172
|
+
if self.deadline is None:
|
|
173
|
+
return None
|
|
174
|
+
return max(self.deadline - time.monotonic(), 0.0)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def cancelled(self) -> bool:
|
|
178
|
+
return self.cancel.is_set()
|
|
179
|
+
|
|
180
|
+
def raise_if_cancelled(self) -> None:
|
|
181
|
+
"""Cancellation checkpoint for long loops inside a tool.
|
|
182
|
+
|
|
183
|
+
`asyncio.CancelledError` covers the awaits; this covers the stretches between
|
|
184
|
+
them, where a tool is busy in Python and otherwise unreachable.
|
|
185
|
+
"""
|
|
186
|
+
if self.cancel.is_set():
|
|
187
|
+
raise ToolCancelled(f"{self.tool_name or 'tool'} was cancelled")
|
|
188
|
+
|
|
189
|
+
def for_tool(self, tool_name: str, *, tool_call_id: str = "", attempt: int = 1) -> "Runtime[CtxT]":
|
|
190
|
+
"""A copy scoped to one invocation.
|
|
191
|
+
|
|
192
|
+
Copied, not mutated: sibling tools run concurrently, and a shared runtime whose
|
|
193
|
+
`tool_name` changes underneath them mislabels every log line they emit.
|
|
194
|
+
"""
|
|
195
|
+
clone = Runtime(context=self.context)
|
|
196
|
+
for slot, value in vars(self).items():
|
|
197
|
+
setattr(clone, slot, value)
|
|
198
|
+
clone.tool_name = tool_name
|
|
199
|
+
clone.tool_call_id = tool_call_id
|
|
200
|
+
clone.attempt = attempt
|
|
201
|
+
clone.execution_id = uuid.uuid4().hex[:12]
|
|
202
|
+
clone.started_at = time.monotonic()
|
|
203
|
+
return clone
|
|
204
|
+
|
|
205
|
+
# ---- capabilities ----
|
|
206
|
+
|
|
207
|
+
async def progress(self, text: str) -> None:
|
|
208
|
+
"""Report a line of progress. A no-op when nothing is listening."""
|
|
209
|
+
if self.on_progress is None:
|
|
210
|
+
return
|
|
211
|
+
outcome = self.on_progress(text)
|
|
212
|
+
if inspect.isawaitable(outcome):
|
|
213
|
+
await outcome
|
|
214
|
+
|
|
215
|
+
def emit(self, agent_name: str, event: Any) -> None:
|
|
216
|
+
"""Hand one sub-agent event to whoever is watching. A no-op when nobody is.
|
|
217
|
+
|
|
218
|
+
Not a coroutine, and deliberately swallowing what the host raises: this is the
|
|
219
|
+
only callback whose failure has nothing to do with the work. A UI that blew up
|
|
220
|
+
rendering a delta must not take the sub-agent down with it.
|
|
221
|
+
"""
|
|
222
|
+
if self.on_event is None:
|
|
223
|
+
return
|
|
224
|
+
try:
|
|
225
|
+
self.on_event(agent_name, event)
|
|
226
|
+
except Exception: # noqa: BLE001 — see above
|
|
227
|
+
self.logger.debug("on_event raised for %s", agent_name, exc_info=True)
|
|
228
|
+
|
|
229
|
+
async def request_approval(self, permission: ToolPermission, arguments: dict[str, Any]) -> bool:
|
|
230
|
+
"""Ask the human to approve this call, per the mode in force.
|
|
231
|
+
|
|
232
|
+
True when the tool may proceed. With no approval callback wired the call is
|
|
233
|
+
refused, not allowed — a headless run must never upgrade itself to `full-auto`.
|
|
234
|
+
"""
|
|
235
|
+
if is_forbidden(self.approval_mode, permission):
|
|
236
|
+
raise ToolForbidden(
|
|
237
|
+
f"`{self.tool_name}` needs {permission.value} access, which the "
|
|
238
|
+
f"{self.approval_mode!r} approval mode does not allow. Switch modes with "
|
|
239
|
+
"/mode, or use a tool that only reads."
|
|
240
|
+
)
|
|
241
|
+
if not requires_approval(self.approval_mode, permission):
|
|
242
|
+
return True
|
|
243
|
+
if self.on_approval is None:
|
|
244
|
+
raise ToolDenied(
|
|
245
|
+
f"`{self.tool_name}` needs approval under the {self.approval_mode!r} mode, "
|
|
246
|
+
"but this session has no way to ask. Run in a mode that permits it."
|
|
247
|
+
)
|
|
248
|
+
return await self.on_approval(
|
|
249
|
+
ApprovalRequest(
|
|
250
|
+
tool_name=self.tool_name,
|
|
251
|
+
permission=permission,
|
|
252
|
+
arguments=arguments,
|
|
253
|
+
agent_name=self.agent_name,
|
|
254
|
+
execution_id=self.execution_id,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def ask(self, question: Question) -> str:
|
|
259
|
+
"""Put a question to the user and wait for the answer."""
|
|
260
|
+
if self.on_ask is None:
|
|
261
|
+
raise ToolError(
|
|
262
|
+
"There is no user attached to this session to ask. Decide with the "
|
|
263
|
+
"information you have and state the assumption you made."
|
|
264
|
+
)
|
|
265
|
+
return await self.on_ask(question)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
current_runtime: contextvars.ContextVar[Runtime[Any] | None] = contextvars.ContextVar(
|
|
269
|
+
"stcode_current_runtime", default=None
|
|
270
|
+
)
|
|
271
|
+
"""The runtime a bare `await read(...)` picks up.
|
|
272
|
+
|
|
273
|
+
A context variable, not a global: sub-agents run concurrently in one process and each
|
|
274
|
+
needs its own cwd, scope, and approval mode. `Harness.bind()` sets it for a turn.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---- tools ---------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _runtime_parameter(fn: Callable[..., Any]) -> str | None:
|
|
282
|
+
"""Find the parameter annotated `Runtime` / `Runtime[T]`, if any.
|
|
283
|
+
|
|
284
|
+
Matched on the annotation, not the name — a tool may call it `rt`, and a plain
|
|
285
|
+
parameter that happens to be named `runtime` should stay a real parameter.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
289
|
+
except Exception:
|
|
290
|
+
hints = {}
|
|
291
|
+
for name, parameter in inspect.signature(fn).parameters.items():
|
|
292
|
+
annotation = hints.get(name, parameter.annotation)
|
|
293
|
+
if annotation is Runtime or get_origin(annotation) is Runtime:
|
|
294
|
+
return name
|
|
295
|
+
# Annotated[Runtime[Ctx], ...] — unwrap one layer and look again.
|
|
296
|
+
for arg in get_args(annotation):
|
|
297
|
+
if arg is Runtime or get_origin(arg) is Runtime:
|
|
298
|
+
return name
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class Tool(Generic[CtxT]):
|
|
303
|
+
"""One callable, presented three ways.
|
|
304
|
+
|
|
305
|
+
* `to_tool_definition()` — the schema a provider advertises.
|
|
306
|
+
* `invoke(arguments, runtime)` — the gated, validated path the agent loop uses.
|
|
307
|
+
Never raises for a tool-level problem; failures come back as results.
|
|
308
|
+
* `__call__(...)` — an ordinary Python call for REPL and test code, raising
|
|
309
|
+
normally. Takes the runtime from `current_runtime` or an explicit keyword.
|
|
310
|
+
|
|
311
|
+
Built from a decorated function, except when the schema is authored elsewhere (an
|
|
312
|
+
MCP server's), passed as `input_schema`.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(
|
|
316
|
+
self,
|
|
317
|
+
fn: Callable[..., Any],
|
|
318
|
+
*,
|
|
319
|
+
name: str | None = None,
|
|
320
|
+
description: str | None = None,
|
|
321
|
+
permission: ToolPermission = ToolPermission.READ,
|
|
322
|
+
timeout: float | None = None,
|
|
323
|
+
max_output: int = DEFAULT_MAX_OUTPUT,
|
|
324
|
+
spill: bool = True,
|
|
325
|
+
permission_for: Callable[[dict[str, Any]], ToolPermission] | None = None,
|
|
326
|
+
input_schema: dict[str, Any] | None = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
self.fn = fn
|
|
329
|
+
self.name: str = name or str(getattr(fn, "__name__", "tool"))
|
|
330
|
+
self.permission = permission
|
|
331
|
+
self.timeout = timeout
|
|
332
|
+
self.max_output = max_output
|
|
333
|
+
self.spill = spill
|
|
334
|
+
self.permission_for = permission_for
|
|
335
|
+
|
|
336
|
+
prose, _ = split_docstring(fn)
|
|
337
|
+
self.description = description if description is not None else prose
|
|
338
|
+
if not self.description:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"tool {self.name!r} has no description: give it a docstring, or pass "
|
|
341
|
+
"description=... . The description is the whole prompt the model reads "
|
|
342
|
+
"for this tool."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
self.runtime_param = _runtime_parameter(fn)
|
|
346
|
+
if input_schema is not None:
|
|
347
|
+
# Authored elsewhere (an MCP server describing its own tool), so used
|
|
348
|
+
# verbatim and unvalidated: a model we invented would reject calls the
|
|
349
|
+
# server would have accepted.
|
|
350
|
+
self.params_model = None
|
|
351
|
+
self.input_schema = input_schema
|
|
352
|
+
else:
|
|
353
|
+
excluded = frozenset({self.runtime_param}) if self.runtime_param else frozenset()
|
|
354
|
+
self.params_model = build_params_model(fn, exclude=excluded)
|
|
355
|
+
self.input_schema = json_schema_for(self.params_model)
|
|
356
|
+
self.is_async = inspect.iscoroutinefunction(fn)
|
|
357
|
+
|
|
358
|
+
# `help(read)` in the REPL should show the tool's docstring, not this class's.
|
|
359
|
+
self.__doc__ = inspect.getdoc(fn)
|
|
360
|
+
self.__name__ = self.name
|
|
361
|
+
self.__signature__ = inspect.signature(fn)
|
|
362
|
+
|
|
363
|
+
def __repr__(self) -> str:
|
|
364
|
+
return f"<tool {self.name}({', '.join(self.input_schema.get('properties', {}))})>"
|
|
365
|
+
|
|
366
|
+
def to_tool_definition(self) -> ToolDefinition:
|
|
367
|
+
"""The provider-facing shape. Byte-stable across turns, so it stays inside the
|
|
368
|
+
cached prompt prefix."""
|
|
369
|
+
return ToolDefinition(
|
|
370
|
+
name=self.name, description=self.description, input_schema=self.input_schema
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def definition(self) -> ToolDefinition:
|
|
375
|
+
return self.to_tool_definition()
|
|
376
|
+
|
|
377
|
+
def effective_permission(self, arguments: dict[str, Any]) -> ToolPermission:
|
|
378
|
+
"""The permission this *particular* call needs.
|
|
379
|
+
|
|
380
|
+
Almost always the declared one. `bash` is the exception: `git status` and
|
|
381
|
+
`rm -rf build/` arrive through the same tool. A call may narrow the class it
|
|
382
|
+
claims, never widen it — the declared permission is what the mode was checked
|
|
383
|
+
against when the tool set was assembled.
|
|
384
|
+
"""
|
|
385
|
+
if self.permission_for is None:
|
|
386
|
+
return self.permission
|
|
387
|
+
return self.permission_for(arguments)
|
|
388
|
+
|
|
389
|
+
# ---- calling ----
|
|
390
|
+
|
|
391
|
+
def bind(self, runtime: Runtime[CtxT]) -> Callable[..., Awaitable[Any]]:
|
|
392
|
+
"""A plain async callable with `runtime` already supplied, so REPL code reads
|
|
393
|
+
as `await read("src/main.py")` instead of threading plumbing through."""
|
|
394
|
+
|
|
395
|
+
async def bound(*args: Any, **kwargs: Any) -> Any:
|
|
396
|
+
return await self._call(args, kwargs, runtime)
|
|
397
|
+
|
|
398
|
+
bound.__name__ = self.name
|
|
399
|
+
bound.__doc__ = self.__doc__
|
|
400
|
+
return bound
|
|
401
|
+
|
|
402
|
+
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
403
|
+
runtime = kwargs.pop("runtime", None) or current_runtime.get()
|
|
404
|
+
return await self._call(args, kwargs, runtime)
|
|
405
|
+
|
|
406
|
+
async def _call(self, args: tuple[Any, ...], kwargs: dict[str, Any], runtime: Any) -> Any:
|
|
407
|
+
if self.runtime_param is not None:
|
|
408
|
+
if runtime is None:
|
|
409
|
+
raise ToolError(
|
|
410
|
+
f"`{self.name}` needs a Runtime and none is bound. Call it through "
|
|
411
|
+
"Harness.invoke(), or bind one with `Harness.bind()` first."
|
|
412
|
+
)
|
|
413
|
+
kwargs[self.runtime_param] = runtime
|
|
414
|
+
outcome = self.fn(*args, **kwargs)
|
|
415
|
+
if inspect.isawaitable(outcome):
|
|
416
|
+
return await outcome
|
|
417
|
+
return outcome
|
|
418
|
+
|
|
419
|
+
async def invoke(
|
|
420
|
+
self,
|
|
421
|
+
arguments: dict[str, Any] | None = None,
|
|
422
|
+
runtime: Runtime[CtxT] | None = None,
|
|
423
|
+
*,
|
|
424
|
+
tool_call_id: str = "",
|
|
425
|
+
) -> ToolResult:
|
|
426
|
+
"""Run a model-issued call: validate, gate, execute, render.
|
|
427
|
+
|
|
428
|
+
Never raises for a tool-level failure. Bad arguments, a denied approval, a
|
|
429
|
+
timeout, a bug inside the tool — all come back as `ToolResult(is_error=True)`,
|
|
430
|
+
because the loop's only move is to hand a `tool_result` back. A raise here
|
|
431
|
+
would take down the whole turn.
|
|
432
|
+
"""
|
|
433
|
+
runtime = runtime or current_runtime.get()
|
|
434
|
+
if runtime is None:
|
|
435
|
+
return ToolResult.error(f"`{self.name}` was invoked with no runtime bound.")
|
|
436
|
+
call = runtime.for_tool(self.name, tool_call_id=tool_call_id)
|
|
437
|
+
|
|
438
|
+
if self.params_model is None:
|
|
439
|
+
kwargs = dict(arguments or {})
|
|
440
|
+
else:
|
|
441
|
+
try:
|
|
442
|
+
validated = self.params_model.model_validate(arguments or {})
|
|
443
|
+
except ValidationError as exc:
|
|
444
|
+
return ToolResult.error(
|
|
445
|
+
f"Invalid arguments for `{self.name}`:\n{_render_validation_error(exc)}",
|
|
446
|
+
tool=self.name,
|
|
447
|
+
)
|
|
448
|
+
# Attribute access, not `model_dump()`: dumping converts nested models back
|
|
449
|
+
# into dicts, so `todos: list[TodoItem]` would arrive as a list of dicts.
|
|
450
|
+
kwargs = {name: getattr(validated, name) for name in type(validated).model_fields}
|
|
451
|
+
try:
|
|
452
|
+
call.raise_if_cancelled()
|
|
453
|
+
if not await call.request_approval(self.effective_permission(kwargs), kwargs):
|
|
454
|
+
raise ToolDenied(
|
|
455
|
+
f"The user declined to run `{self.name}`. Do not retry it — ask what "
|
|
456
|
+
"they would prefer, or take a different approach."
|
|
457
|
+
)
|
|
458
|
+
value = await self._run(kwargs, call)
|
|
459
|
+
except ToolError as exc:
|
|
460
|
+
return ToolResult.error(str(exc), tool=self.name, kind=type(exc).__name__)
|
|
461
|
+
except asyncio.CancelledError:
|
|
462
|
+
raise
|
|
463
|
+
except Exception as exc:
|
|
464
|
+
# A bug in the tool. Log the traceback; send back only what is actionable.
|
|
465
|
+
call.logger.exception("tool %s raised", self.name, extra={"tool": self.name})
|
|
466
|
+
return ToolResult.error(f"`{self.name}` failed: {type(exc).__name__}: {exc}", tool=self.name)
|
|
467
|
+
|
|
468
|
+
return self._render(value, call)
|
|
469
|
+
|
|
470
|
+
async def _run(self, kwargs: dict[str, Any], call: Runtime[CtxT]) -> Any:
|
|
471
|
+
if self.runtime_param is not None:
|
|
472
|
+
kwargs = {**kwargs, self.runtime_param: call}
|
|
473
|
+
|
|
474
|
+
if self.is_async:
|
|
475
|
+
coro = self.fn(**kwargs)
|
|
476
|
+
else:
|
|
477
|
+
# No blocking I/O in the agent loop. A sync tool is assumed to block, so
|
|
478
|
+
# it runs off the loop thread.
|
|
479
|
+
coro = asyncio.to_thread(self.fn, **kwargs)
|
|
480
|
+
|
|
481
|
+
budget = self.timeout if self.timeout is not None else call.remaining
|
|
482
|
+
try:
|
|
483
|
+
if budget is None:
|
|
484
|
+
return await coro
|
|
485
|
+
return await asyncio.wait_for(coro, budget)
|
|
486
|
+
except asyncio.TimeoutError:
|
|
487
|
+
raise ToolTimeout(
|
|
488
|
+
f"`{self.name}` exceeded its {budget:.0f}s budget. Narrow the request "
|
|
489
|
+
"(a smaller path, a tighter pattern) or raise the timeout."
|
|
490
|
+
) from None
|
|
491
|
+
|
|
492
|
+
def _render(self, value: Any, call: Runtime[CtxT]) -> ToolResult:
|
|
493
|
+
"""Turn what the function returned into what the model reads.
|
|
494
|
+
|
|
495
|
+
A `ToolResult` passes straight through; anything else is stringified and elided
|
|
496
|
+
at `max_output`. The elision hint names `tool_out["..."]` only when a REPL is
|
|
497
|
+
attached to hold it — the only hint allowed is a true one.
|
|
498
|
+
"""
|
|
499
|
+
if isinstance(value, ToolResult):
|
|
500
|
+
result = value
|
|
501
|
+
else:
|
|
502
|
+
result = ToolResult(content=_stringify(value), payload=value)
|
|
503
|
+
|
|
504
|
+
if result.is_error or len(result.content) <= self.max_output:
|
|
505
|
+
return result
|
|
506
|
+
|
|
507
|
+
hint = NARROW_REQUEST_HINT
|
|
508
|
+
if self.spill:
|
|
509
|
+
output_id = result.output_id or f"{self.name}_{call.execution_id}"
|
|
510
|
+
call.outputs[output_id] = result.payload if result.payload is not None else result.content
|
|
511
|
+
result.output_id = output_id
|
|
512
|
+
if call.outputs_reachable:
|
|
513
|
+
hint = tool_out_hint(output_id)
|
|
514
|
+
result.content = elide(result.content, self.max_output, hint=hint)
|
|
515
|
+
return result
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _stringify(value: Any) -> str:
|
|
519
|
+
if value is None:
|
|
520
|
+
return "(no output)"
|
|
521
|
+
if isinstance(value, str):
|
|
522
|
+
return value
|
|
523
|
+
if isinstance(value, BaseModel):
|
|
524
|
+
return value.model_dump_json(indent=2)
|
|
525
|
+
try:
|
|
526
|
+
return json.dumps(value, indent=2, default=str)
|
|
527
|
+
except (TypeError, ValueError):
|
|
528
|
+
return repr(value)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _render_validation_error(exc: ValidationError) -> str:
|
|
532
|
+
"""Pydantic names a model the tool author invented and the LLM has never heard of.
|
|
533
|
+
Report the argument instead."""
|
|
534
|
+
lines = []
|
|
535
|
+
for error in exc.errors():
|
|
536
|
+
where = ".".join(str(part) for part in error["loc"]) or "(arguments)"
|
|
537
|
+
lines.append(f" {where}: {error['msg']}")
|
|
538
|
+
return "\n".join(lines)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---- the decorator -------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@overload
|
|
545
|
+
def tool(fn: Callable[..., Any], /) -> Tool[Any]: ...
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@overload
|
|
549
|
+
def tool(
|
|
550
|
+
*,
|
|
551
|
+
name: str | None = ...,
|
|
552
|
+
description: str | None = ...,
|
|
553
|
+
permission: ToolPermission = ...,
|
|
554
|
+
timeout: float | None = ...,
|
|
555
|
+
max_output: int = ...,
|
|
556
|
+
spill: bool = ...,
|
|
557
|
+
permission_for: Callable[[dict[str, Any]], ToolPermission] | None = ...,
|
|
558
|
+
) -> Callable[[Callable[..., Any]], Tool[Any]]: ...
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def tool(
|
|
562
|
+
fn: Callable[..., Any] | None = None,
|
|
563
|
+
/,
|
|
564
|
+
*,
|
|
565
|
+
name: str | None = None,
|
|
566
|
+
description: str | None = None,
|
|
567
|
+
permission: ToolPermission = ToolPermission.READ,
|
|
568
|
+
timeout: float | None = None,
|
|
569
|
+
max_output: int = DEFAULT_MAX_OUTPUT,
|
|
570
|
+
spill: bool = True,
|
|
571
|
+
permission_for: Callable[[dict[str, Any]], ToolPermission] | None = None,
|
|
572
|
+
) -> Tool[Any] | Callable[[Callable[..., Any]], Tool[Any]]:
|
|
573
|
+
"""Turn a function into a tool. Usable bare (`@tool`) or configured (`@tool(...)`).
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
name: Overrides the function's name. Prefer renaming the function — an
|
|
577
|
+
advertised name that disagrees with the Python one confuses whoever reads
|
|
578
|
+
the trajectory next.
|
|
579
|
+
description: Overrides the docstring. Prefer the docstring: it is the prompt,
|
|
580
|
+
and keeping it next to the code is why that works.
|
|
581
|
+
permission: The class of side effect, which decides whether this needs a
|
|
582
|
+
human's yes. Defaults to `READ` — under-claiming just asks too often,
|
|
583
|
+
over-claiming writes without asking.
|
|
584
|
+
timeout: Seconds before the call is abandoned. `None` inherits the runtime's
|
|
585
|
+
remaining deadline, so a tool with no opinion cannot outlive its turn.
|
|
586
|
+
max_output: Chars the model sees before elision. Raise it for bulk retrieval;
|
|
587
|
+
the remainder is in `tool_out` either way.
|
|
588
|
+
spill: Whether an over-long result is parked in `tool_out`. Off only when the
|
|
589
|
+
output is worthless truncated (a progress echo, a confirmation).
|
|
590
|
+
permission_for: Narrows `permission` per call from the validated arguments.
|
|
591
|
+
For tools like `bash`, whose risk is in the argument rather than the tool.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
def decorate(target: Callable[..., Any]) -> Tool[Any]:
|
|
595
|
+
return Tool(
|
|
596
|
+
target,
|
|
597
|
+
name=name,
|
|
598
|
+
description=description,
|
|
599
|
+
permission=permission,
|
|
600
|
+
timeout=timeout,
|
|
601
|
+
max_output=max_output,
|
|
602
|
+
spill=spill,
|
|
603
|
+
permission_for=permission_for,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return decorate(fn) if fn is not None else decorate
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def to_tool_definition(target: Tool[Any] | Callable[..., Any]) -> ToolDefinition:
|
|
610
|
+
"""The `ToolDefinition` for a tool, or for an undecorated function.
|
|
611
|
+
|
|
612
|
+
MCP-backed and generated tools arrive as plain functions, and deriving their schema
|
|
613
|
+
is the same work either way.
|
|
614
|
+
"""
|
|
615
|
+
if isinstance(target, Tool):
|
|
616
|
+
return target.to_tool_definition()
|
|
617
|
+
return Tool(target).to_tool_definition()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
__all__ = [
|
|
621
|
+
"ApprovalFn",
|
|
622
|
+
"ApprovalRequest",
|
|
623
|
+
"AskFn",
|
|
624
|
+
"EventFn",
|
|
625
|
+
"DEFAULT_MAX_OUTPUT",
|
|
626
|
+
"ProgressFn",
|
|
627
|
+
"Question",
|
|
628
|
+
"Runtime",
|
|
629
|
+
"Tool",
|
|
630
|
+
"ToolCancelled",
|
|
631
|
+
"ToolDenied",
|
|
632
|
+
"ToolError",
|
|
633
|
+
"ToolForbidden",
|
|
634
|
+
"ToolPermission",
|
|
635
|
+
"ToolResult",
|
|
636
|
+
"ToolTimeout",
|
|
637
|
+
"current_runtime",
|
|
638
|
+
"to_tool_definition",
|
|
639
|
+
"tool",
|
|
640
|
+
]
|