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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. 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
+ ]