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,417 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Harness — the one object the agent loop talks to.
|
|
3
|
+
|
|
4
|
+
harness = await Harness.create(cwd=project_root, approval_mode="auto-edit")
|
|
5
|
+
system = harness.system_prompt()
|
|
6
|
+
tools = harness.tool_definitions()
|
|
7
|
+
result = await harness.invoke("read", {"path": "src/main.py"}, tool_call_id=call.id)
|
|
8
|
+
|
|
9
|
+
Assembles everything under `core/harness/`: tools, skills, MCP servers, the prompt, and
|
|
10
|
+
the `Runtime` that carries them into a call. The agent loop needs two verbs from here —
|
|
11
|
+
"what do I advertise" and "run this" — and nothing about how either is built.
|
|
12
|
+
|
|
13
|
+
A `Harness` is **per agent**, not per process: its context holds a cwd, a write scope
|
|
14
|
+
and an approval mode, and two sub-agents must be able to have different scopes at once.
|
|
15
|
+
`for_subagent()` makes the narrowed copy; mutating one harness between spawns is the
|
|
16
|
+
bug that rule exists to prevent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import contextlib
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Iterator, MutableMapping, Sequence
|
|
25
|
+
|
|
26
|
+
from stcode.core.common import trace
|
|
27
|
+
from stcode.core.common.tools import ToolDefinition, ToolResult
|
|
28
|
+
from stcode.core.harness.approvals import DEFAULT_APPROVAL_MODE, ApprovalMode
|
|
29
|
+
from stcode.core.harness.context import HarnessContext, git_context
|
|
30
|
+
from stcode.core.harness.mcp import (
|
|
31
|
+
MCP_CODE_DIRNAME,
|
|
32
|
+
MCPManager,
|
|
33
|
+
generate_server_code,
|
|
34
|
+
load_mcp_config,
|
|
35
|
+
)
|
|
36
|
+
from stcode.core.harness.outputs import OutputStore
|
|
37
|
+
from stcode.core.harness.prompts import (
|
|
38
|
+
PromptMode,
|
|
39
|
+
build_system_prompt,
|
|
40
|
+
mode_for,
|
|
41
|
+
)
|
|
42
|
+
from stcode.core.harness.skills import SkillRegistry
|
|
43
|
+
from stcode.core.harness.tools import BUILTIN_TOOLS, MAIN_TOOLS, WORKER_TOOLS, ToolRegistry
|
|
44
|
+
from stcode.core.harness.tools.base import (
|
|
45
|
+
ApprovalFn,
|
|
46
|
+
AskFn,
|
|
47
|
+
EventFn,
|
|
48
|
+
ProgressFn,
|
|
49
|
+
Runtime,
|
|
50
|
+
Tool,
|
|
51
|
+
current_runtime,
|
|
52
|
+
)
|
|
53
|
+
from stcode.core.repl import PyREPL
|
|
54
|
+
|
|
55
|
+
PROJECT_INSTRUCTION_FILES = ("CLAUDE.md", "AGENTS.md", ".stcode/instructions.md")
|
|
56
|
+
"""Read in order; first hit wins. `AGENTS.md` is the cross-agent convention, and a repo
|
|
57
|
+
that has one has already written down how it wants to be worked in."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Harness:
|
|
61
|
+
"""One agent's capabilities: its tools, its skills, its prompt, its permissions."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
context: HarnessContext | None = None,
|
|
66
|
+
*,
|
|
67
|
+
tools: Sequence[Tool[Any]] | None = None,
|
|
68
|
+
allowed: Sequence[str] | None = None,
|
|
69
|
+
approval_mode: ApprovalMode = DEFAULT_APPROVAL_MODE,
|
|
70
|
+
session_id: str = "",
|
|
71
|
+
agent_name: str = "main",
|
|
72
|
+
depth: int = 0,
|
|
73
|
+
project_instructions: str = "",
|
|
74
|
+
extra_prompt: str = "",
|
|
75
|
+
role: str = "",
|
|
76
|
+
outputs: MutableMapping[str, Any] | None = None,
|
|
77
|
+
mcp_catalogue: str = "",
|
|
78
|
+
on_progress: ProgressFn | None = None,
|
|
79
|
+
on_approval: ApprovalFn | None = None,
|
|
80
|
+
on_ask: AskFn | None = None,
|
|
81
|
+
on_event: EventFn | None = None,
|
|
82
|
+
mcp: MCPManager | None = None,
|
|
83
|
+
cancel: asyncio.Event | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.context = context if context is not None else HarnessContext()
|
|
86
|
+
self.registry = ToolRegistry(tools if tools is not None else BUILTIN_TOOLS)
|
|
87
|
+
# An explicit `tools=` list is already the caller's selection, so allow all of
|
|
88
|
+
# it. The built-in set is not — `MAIN_TOOLS` is the narrower default.
|
|
89
|
+
if allowed is not None:
|
|
90
|
+
self.allowed: list[str] | None = list(allowed)
|
|
91
|
+
elif tools is not None:
|
|
92
|
+
self.allowed = None
|
|
93
|
+
else:
|
|
94
|
+
self.allowed = list(MAIN_TOOLS)
|
|
95
|
+
self.approval_mode = approval_mode
|
|
96
|
+
self.session_id = session_id
|
|
97
|
+
self.agent_name = agent_name
|
|
98
|
+
self.depth = depth
|
|
99
|
+
self.project_instructions = project_instructions
|
|
100
|
+
self.extra_prompt = extra_prompt
|
|
101
|
+
self.role = role
|
|
102
|
+
"""The agent's prompt body, already resolved. A string, not a name: `Harness`
|
|
103
|
+
should not know where agent profiles live on disk."""
|
|
104
|
+
|
|
105
|
+
self.mcp = mcp
|
|
106
|
+
self.mcp_catalogue = mcp_catalogue
|
|
107
|
+
"""Server and tool *names*, for the prompt. Empty in `tools` mode, where the
|
|
108
|
+
definitions are advertised instead."""
|
|
109
|
+
|
|
110
|
+
self.mcp_servers: dict[str, list[str]] = {}
|
|
111
|
+
"""Server -> tool names, for a client that wants to show what is connected.
|
|
112
|
+
|
|
113
|
+
Kept because in `code` mode the manager is closed as soon as it has said what
|
|
114
|
+
each server offers: the connection is gone by then, the facts are not, and
|
|
115
|
+
`/mcp` still has to be able to answer."""
|
|
116
|
+
# Whatever `elide` cuts is parked here; `_share_output` copies it into the
|
|
117
|
+
# REPL's `tool_out` so the model can reach it. Bounded: nothing else would ever
|
|
118
|
+
# remove an entry, and a long session would hold every large result it made.
|
|
119
|
+
self.outputs: MutableMapping[str, Any] = outputs if outputs is not None else OutputStore()
|
|
120
|
+
self.on_progress = on_progress
|
|
121
|
+
self.on_approval = on_approval
|
|
122
|
+
self.on_ask = on_ask
|
|
123
|
+
self.on_event = on_event
|
|
124
|
+
# One event, shared by every `Runtime` this harness hands out, so a single
|
|
125
|
+
# `set()` reaches every tool in flight. A per-call event nobody holds a
|
|
126
|
+
# reference to could never be set at all.
|
|
127
|
+
self.cancel = cancel if cancel is not None else asyncio.Event()
|
|
128
|
+
|
|
129
|
+
# ---- construction ----
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
async def create(
|
|
133
|
+
cls,
|
|
134
|
+
cwd: str | Path | None = None,
|
|
135
|
+
*,
|
|
136
|
+
approval_mode: ApprovalMode = DEFAULT_APPROVAL_MODE,
|
|
137
|
+
load_skills: bool = True,
|
|
138
|
+
load_mcp: bool = True,
|
|
139
|
+
load_git: bool = True,
|
|
140
|
+
load_repl: bool = True,
|
|
141
|
+
role: str = "",
|
|
142
|
+
agent_prompt: str = "",
|
|
143
|
+
mcp_expose: str = "code",
|
|
144
|
+
mcp_config: str | Path | None = None,
|
|
145
|
+
**kwargs: Any,
|
|
146
|
+
) -> "Harness":
|
|
147
|
+
"""Build a harness with skills discovered and MCP servers connected.
|
|
148
|
+
|
|
149
|
+
Async because connecting to MCP servers is. Both discovery steps fail soft — a
|
|
150
|
+
broken skill or unreachable server is reported, not raised. Neither is a reason
|
|
151
|
+
a coding session cannot start.
|
|
152
|
+
"""
|
|
153
|
+
root = Path(cwd).expanduser() if cwd else Path.cwd()
|
|
154
|
+
if not root.is_dir():
|
|
155
|
+
root = Path.cwd()
|
|
156
|
+
context = kwargs.pop("context", None) or HarnessContext(cwd=root)
|
|
157
|
+
context.cwd = root
|
|
158
|
+
if load_skills and context.skills is None:
|
|
159
|
+
context.skills = SkillRegistry.discover(root)
|
|
160
|
+
if load_git and not context.git:
|
|
161
|
+
context.git = await git_context(root)
|
|
162
|
+
if load_repl and context.repl is None:
|
|
163
|
+
# Constructed, not started: the first cell spawns the subprocess. Most
|
|
164
|
+
# sessions never run one.
|
|
165
|
+
context.repl = PyREPL(cwd=root, env=context.env)
|
|
166
|
+
|
|
167
|
+
kwargs.setdefault("role", agent_prompt.strip())
|
|
168
|
+
kwargs.setdefault("project_instructions", read_project_instructions(root))
|
|
169
|
+
harness = cls(context, approval_mode=approval_mode, **kwargs)
|
|
170
|
+
|
|
171
|
+
if load_mcp:
|
|
172
|
+
servers = load_mcp_config(mcp_config, cwd=root)
|
|
173
|
+
if servers:
|
|
174
|
+
manager = MCPManager()
|
|
175
|
+
await manager.open(servers)
|
|
176
|
+
harness.mcp_servers = {
|
|
177
|
+
server: sorted(tools) for server, tools in manager.schemas().items()
|
|
178
|
+
}
|
|
179
|
+
if mcp_expose == "tools":
|
|
180
|
+
harness.mcp = manager
|
|
181
|
+
harness.registry.extend(manager.tools.values(), replace=True)
|
|
182
|
+
# Registering is not advertising: `allowed` is a fixed list, so an
|
|
183
|
+
# MCP tool nobody adds to it stays connected and invisible.
|
|
184
|
+
harness.allow(*manager.tools)
|
|
185
|
+
else:
|
|
186
|
+
# Code mode: we connected only to ask what each server offers. The
|
|
187
|
+
# stubs open their own connection inside the REPL, so holding this
|
|
188
|
+
# one would mean two per server.
|
|
189
|
+
generate_server_code(root, manager.schemas())
|
|
190
|
+
harness.mcp_catalogue = manager.catalogue()
|
|
191
|
+
await manager.aclose()
|
|
192
|
+
return harness
|
|
193
|
+
|
|
194
|
+
def allow(self, *names: str) -> None:
|
|
195
|
+
"""Add tools to what this agent may see. No-op when it may already see everything."""
|
|
196
|
+
if self.allowed is None:
|
|
197
|
+
return
|
|
198
|
+
self.allowed += [name for name in names if name not in self.allowed]
|
|
199
|
+
|
|
200
|
+
def for_subagent(
|
|
201
|
+
self,
|
|
202
|
+
name: str,
|
|
203
|
+
*,
|
|
204
|
+
tools: Sequence[str] | None = None,
|
|
205
|
+
scope: str | Sequence[str] | None = None,
|
|
206
|
+
approval_mode: ApprovalMode | None = None,
|
|
207
|
+
) -> "Harness":
|
|
208
|
+
"""A narrowed harness for a child agent.
|
|
209
|
+
|
|
210
|
+
Shares the parent's tool registry, MCP connections and output store — those are
|
|
211
|
+
session-wide and expensive — but gets its own `HarnessContext`, so its write
|
|
212
|
+
scope, read tracking and todos are genuinely separate. A shared context would
|
|
213
|
+
scope-check against the parent's paths and make the rule unenforceable.
|
|
214
|
+
"""
|
|
215
|
+
scopes = (scope,) if isinstance(scope, str) else tuple(scope or ())
|
|
216
|
+
child_context = HarnessContext(
|
|
217
|
+
cwd=self.context.cwd,
|
|
218
|
+
scope=tuple(self.context.resolve(entry) for entry in scopes),
|
|
219
|
+
skills=self.context.skills,
|
|
220
|
+
session_ctx=self.context.session_ctx,
|
|
221
|
+
env=dict(self.context.env),
|
|
222
|
+
tool_config=self.context.tool_config.model_copy(deep=True),
|
|
223
|
+
)
|
|
224
|
+
child = Harness(
|
|
225
|
+
child_context,
|
|
226
|
+
allowed=list(tools) if tools is not None else list(WORKER_TOOLS),
|
|
227
|
+
approval_mode=approval_mode or self.approval_mode,
|
|
228
|
+
session_id=self.session_id,
|
|
229
|
+
agent_name=name,
|
|
230
|
+
depth=self.depth + 1,
|
|
231
|
+
project_instructions=self.project_instructions,
|
|
232
|
+
extra_prompt=self.extra_prompt,
|
|
233
|
+
role=self.role,
|
|
234
|
+
outputs=self.outputs,
|
|
235
|
+
# No `mcp_catalogue`: a sub-agent has no `repl`, so this would spend
|
|
236
|
+
# tokens advertising a dead end.
|
|
237
|
+
on_progress=self.on_progress,
|
|
238
|
+
on_approval=self.on_approval,
|
|
239
|
+
on_ask=self.on_ask,
|
|
240
|
+
# A sub-agent gets no `task`, so it never emits; passed anyway so the
|
|
241
|
+
# arrow is one-way and a future nested case does not silently go dark.
|
|
242
|
+
on_event=self.on_event,
|
|
243
|
+
mcp=self.mcp,
|
|
244
|
+
# Interrupting the parent must reach its children: a sub-agent still
|
|
245
|
+
# editing files after Esc is exactly the orphan to avoid.
|
|
246
|
+
cancel=self.cancel,
|
|
247
|
+
)
|
|
248
|
+
child.registry = self.registry
|
|
249
|
+
return child
|
|
250
|
+
|
|
251
|
+
# ---- what the model sees ----
|
|
252
|
+
|
|
253
|
+
def tool_names(self) -> list[str]:
|
|
254
|
+
return [entry.name for entry in self.registry.select(self.allowed, approval_mode=self.approval_mode)]
|
|
255
|
+
|
|
256
|
+
def tool_definitions(self, names: Sequence[str] | None = None) -> list[ToolDefinition]:
|
|
257
|
+
"""The tool list for a turn, filtered by this agent's allowance and its mode."""
|
|
258
|
+
return self.registry.definitions(
|
|
259
|
+
names if names is not None else self.allowed, approval_mode=self.approval_mode
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def system_prompt(self, *, mode: PromptMode | None = None, task: str = "") -> str:
|
|
263
|
+
"""This agent's system prompt.
|
|
264
|
+
|
|
265
|
+
`subagent` is derived from `depth`, not passed in: a harness from
|
|
266
|
+
`for_subagent()` is a sub-agent by construction, and a caller allowed to say
|
|
267
|
+
otherwise will eventually get it wrong.
|
|
268
|
+
"""
|
|
269
|
+
skills = self.context.skills
|
|
270
|
+
return build_system_prompt(
|
|
271
|
+
mode or mode_for(self.approval_mode),
|
|
272
|
+
subagent=self.depth > 0,
|
|
273
|
+
cwd=str(self.context.cwd),
|
|
274
|
+
approval_mode=self.approval_mode,
|
|
275
|
+
tool_names=self.tool_names(),
|
|
276
|
+
skill_catalogue=skills.catalogue() if skills else "",
|
|
277
|
+
role=self.role,
|
|
278
|
+
mcp_catalogue=self.mcp_catalogue,
|
|
279
|
+
mcp_directory=MCP_CODE_DIRNAME,
|
|
280
|
+
project_instructions=self.project_instructions,
|
|
281
|
+
write_scope=", ".join(str(path) for path in self.context.scope),
|
|
282
|
+
todos=self.context.render_todos() if self.context.todos else "",
|
|
283
|
+
git=self.context.git,
|
|
284
|
+
extra="\n\n".join(part for part in (self.extra_prompt, task) if part),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# ---- running a tool ----
|
|
288
|
+
|
|
289
|
+
def runtime(self) -> Runtime[HarnessContext]:
|
|
290
|
+
"""A fresh runtime carrying this harness's policy and callbacks.
|
|
291
|
+
|
|
292
|
+
Per call, not cached: a `Runtime` holds an `execution_id` and a start time, and
|
|
293
|
+
sharing one across concurrent calls mislabels every log line they produce.
|
|
294
|
+
"""
|
|
295
|
+
return Runtime(
|
|
296
|
+
context=self.context,
|
|
297
|
+
cancel=self.cancel,
|
|
298
|
+
session_id=self.session_id,
|
|
299
|
+
agent_name=self.agent_name,
|
|
300
|
+
depth=self.depth,
|
|
301
|
+
approval_mode=self.approval_mode,
|
|
302
|
+
outputs=self.outputs,
|
|
303
|
+
# Decided in exactly one place: an elision may only name `tool_out[...]`
|
|
304
|
+
# if there is a REPL actually holding it.
|
|
305
|
+
outputs_reachable=getattr(self.context, "repl", None) is not None,
|
|
306
|
+
on_progress=self.on_progress,
|
|
307
|
+
on_approval=self.on_approval,
|
|
308
|
+
on_ask=self.on_ask,
|
|
309
|
+
on_event=self.on_event,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
async def invoke(
|
|
313
|
+
self, name: str, arguments: dict[str, Any] | None = None, *, tool_call_id: str = ""
|
|
314
|
+
) -> ToolResult:
|
|
315
|
+
"""Run one model-issued tool call. Never raises for a tool-level failure.
|
|
316
|
+
|
|
317
|
+
An unknown or disallowed name comes back as an error result too — the model
|
|
318
|
+
chose that name, so the model is who needs to hear about it.
|
|
319
|
+
"""
|
|
320
|
+
allowed = {entry.name for entry in self.registry.select(self.allowed, approval_mode=self.approval_mode)}
|
|
321
|
+
if name not in allowed:
|
|
322
|
+
available = ", ".join(sorted(allowed))
|
|
323
|
+
return ToolResult.error(
|
|
324
|
+
f"`{name}` is not available to this agent. You have: {available}.", tool=name
|
|
325
|
+
)
|
|
326
|
+
# `execute_tool <name>` — the GenAI convention's name for this span, so a
|
|
327
|
+
# platform files it next to the model call that asked for it.
|
|
328
|
+
with trace.span(
|
|
329
|
+
f"execute_tool {name}",
|
|
330
|
+
attributes={
|
|
331
|
+
"gen_ai.operation.name": "execute_tool",
|
|
332
|
+
"gen_ai.tool.name": name,
|
|
333
|
+
"gen_ai.tool.call.id": tool_call_id,
|
|
334
|
+
"stcode.agent": self.agent_name,
|
|
335
|
+
},
|
|
336
|
+
) as recorder:
|
|
337
|
+
if trace.records_content():
|
|
338
|
+
recorder.set(stcode__input=arguments)
|
|
339
|
+
result = await self.registry[name].invoke(
|
|
340
|
+
arguments, self.runtime(), tool_call_id=tool_call_id
|
|
341
|
+
)
|
|
342
|
+
recorder.set(**{"stcode.tool.ok": not result.is_error})
|
|
343
|
+
if trace.records_content():
|
|
344
|
+
recorder.set(stcode__output=result.content)
|
|
345
|
+
if result.is_error:
|
|
346
|
+
recorder.fail(RuntimeError(result.content))
|
|
347
|
+
await self._share_output(result)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
async def _share_output(self, result: ToolResult) -> None:
|
|
351
|
+
"""Push an elided result's full payload into the REPL's `tool_out`.
|
|
352
|
+
|
|
353
|
+
This is what makes eliding non-lossy: the model was just told the rest is in
|
|
354
|
+
`tool_out["..."]`, and this puts it there. A dead REPL costs a hint, not a turn.
|
|
355
|
+
"""
|
|
356
|
+
repl = getattr(self.context, "repl", None)
|
|
357
|
+
if repl is None or not result.output_id:
|
|
358
|
+
return
|
|
359
|
+
with contextlib.suppress(Exception):
|
|
360
|
+
await repl.inject({result.output_id: self.outputs.get(result.output_id)})
|
|
361
|
+
|
|
362
|
+
# ---- ambient runtime ----
|
|
363
|
+
|
|
364
|
+
@contextlib.contextmanager
|
|
365
|
+
def bind(self) -> Iterator[Runtime[HarnessContext]]:
|
|
366
|
+
"""Make this harness's runtime the ambient one for the block.
|
|
367
|
+
|
|
368
|
+
Lets in-process code — a test, a cell in this interpreter — call
|
|
369
|
+
`await read("x.py")` without threading a runtime through.
|
|
370
|
+
"""
|
|
371
|
+
runtime = self.runtime()
|
|
372
|
+
token = current_runtime.set(runtime)
|
|
373
|
+
try:
|
|
374
|
+
yield runtime
|
|
375
|
+
finally:
|
|
376
|
+
current_runtime.reset(token)
|
|
377
|
+
|
|
378
|
+
# ---- lifecycle ----
|
|
379
|
+
|
|
380
|
+
async def aclose(self) -> None:
|
|
381
|
+
"""Release MCP connections and kill anything still running in the background.
|
|
382
|
+
|
|
383
|
+
Ctrl-C with sub-agents in flight must leave no orphans; background shells are
|
|
384
|
+
this layer's share of that.
|
|
385
|
+
"""
|
|
386
|
+
for shell in list(self.context.shells.values()):
|
|
387
|
+
if shell.running:
|
|
388
|
+
with contextlib.suppress(ProcessLookupError, OSError):
|
|
389
|
+
shell.process.kill()
|
|
390
|
+
if shell.pump is not None:
|
|
391
|
+
shell.pump.cancel()
|
|
392
|
+
self.context.shells.clear()
|
|
393
|
+
if self.context.repl is not None:
|
|
394
|
+
await self.context.repl.aclose()
|
|
395
|
+
if self.mcp is not None:
|
|
396
|
+
await self.mcp.aclose()
|
|
397
|
+
|
|
398
|
+
async def __aenter__(self) -> "Harness":
|
|
399
|
+
return self
|
|
400
|
+
|
|
401
|
+
async def __aexit__(self, *_exc_info: object) -> None:
|
|
402
|
+
await self.aclose()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def read_project_instructions(root: Path) -> str:
|
|
406
|
+
"""The repository's own guidance for agents working in it, if it has any."""
|
|
407
|
+
for name in PROJECT_INSTRUCTION_FILES:
|
|
408
|
+
candidate = root / name
|
|
409
|
+
if candidate.is_file():
|
|
410
|
+
try:
|
|
411
|
+
return candidate.read_text(encoding="utf-8", errors="replace")
|
|
412
|
+
except OSError:
|
|
413
|
+
continue
|
|
414
|
+
return ""
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
__all__ = ["PROJECT_INSTRUCTION_FILES", "Harness", "read_project_instructions"]
|