coderio 0.4.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.
- coderio/__init__.py +14 -0
- coderio/agent/__init__.py +5 -0
- coderio/agent/_deepagents_compat.py +139 -0
- coderio/agent/command_review.py +140 -0
- coderio/agent/deep_loop.py +758 -0
- coderio/agent/harness.py +768 -0
- coderio/agent/harness_middleware.py +293 -0
- coderio/agent/permission_middleware.py +52 -0
- coderio/agent/prompts.py +304 -0
- coderio/agent/skill_tool.py +70 -0
- coderio/agent/state.py +144 -0
- coderio/agent/stream.py +93 -0
- coderio/cli/__init__.py +0 -0
- coderio/cli/app.py +257 -0
- coderio/cli/commands.py +339 -0
- coderio/cli/credentials.py +70 -0
- coderio/cli/mcp_cmd.py +227 -0
- coderio/cli/multimodal.py +94 -0
- coderio/cli/onboarding.py +270 -0
- coderio/cli/providers.py +113 -0
- coderio/cli/render.py +28 -0
- coderio/cli/repl.py +189 -0
- coderio/cli/run_cmd.py +171 -0
- coderio/cli/skills_cmd.py +77 -0
- coderio/cli/stream.py +224 -0
- coderio/cli/tui.py +1584 -0
- coderio/cli/tui_onboarding.py +465 -0
- coderio/cli/tui_screens.py +334 -0
- coderio/cli/tui_widgets.py +453 -0
- coderio/config/__init__.py +21 -0
- coderio/config/bootstrap.py +38 -0
- coderio/config/loader.py +273 -0
- coderio/config/models.py +193 -0
- coderio/config/trust.py +136 -0
- coderio/llm/__init__.py +3 -0
- coderio/llm/factory.py +164 -0
- coderio/llm/probe.py +153 -0
- coderio/mcp_loader.py +274 -0
- coderio/session/__init__.py +3 -0
- coderio/session/message.py +79 -0
- coderio/session/store.py +244 -0
- coderio/skills/__init__.py +4 -0
- coderio/skills/bundled/executing-plans/SKILL.md +15 -0
- coderio/skills/lion-skills/LICENSE +21 -0
- coderio/skills/lion-skills/README.md +114 -0
- coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
- coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
- coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
- coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
- coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
- coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
- coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
- coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
- coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
- coderio/skills/models.py +66 -0
- coderio/skills/parser.py +50 -0
- coderio/skills/store.py +102 -0
- coderio/tools/__init__.py +94 -0
- coderio/tools/base.py +55 -0
- coderio/tools/bash.py +168 -0
- coderio/tools/command_policy.py +364 -0
- coderio/tools/edit_file.py +55 -0
- coderio/tools/glob_tool.py +21 -0
- coderio/tools/grep_tool.py +90 -0
- coderio/tools/linux_sandbox.py +196 -0
- coderio/tools/list_dir.py +58 -0
- coderio/tools/multi_edit.py +62 -0
- coderio/tools/note.py +72 -0
- coderio/tools/permission.py +214 -0
- coderio/tools/read_file.py +51 -0
- coderio/tools/sandbox_runner.py +113 -0
- coderio/tools/todo.py +71 -0
- coderio/tools/web_fetch.py +188 -0
- coderio/tools/web_search.py +49 -0
- coderio/tools/win_job.py +214 -0
- coderio/tools/win_sandbox.py +639 -0
- coderio/tools/write_file.py +25 -0
- coderio-0.4.0.dist-info/METADATA +490 -0
- coderio-0.4.0.dist-info/RECORD +97 -0
- coderio-0.4.0.dist-info/WHEEL +4 -0
- coderio-0.4.0.dist-info/entry_points.txt +2 -0
- coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
"""deepagents-backed production agent engine for coderio.
|
|
2
|
+
|
|
3
|
+
deepagents provides context management (offload + summarization), a
|
|
4
|
+
filesystem/shell backend, subagents, and task planning.
|
|
5
|
+
|
|
6
|
+
What coderio ADDS on top via middleware:
|
|
7
|
+
- HarnessMiddleware: the "wrote code but never verified → block done" hard
|
|
8
|
+
constraint (4 gates). deepagents trusts the agent; coderio does not.
|
|
9
|
+
- PermissionMiddleware: 4-tier access system (plan/confirm/auto_edit/full).
|
|
10
|
+
|
|
11
|
+
This module is the sole production engine, invoked by the TUI.
|
|
12
|
+
|
|
13
|
+
Streaming: uses THREE stream modes in parallel —
|
|
14
|
+
- 'messages': AIMessageChunk → on_token / on_thinking (token-by-token stream)
|
|
15
|
+
- 'updates': complete messages → on_tool_start / on_tool_end / usage
|
|
16
|
+
- 'custom': harness signals → on_harness_continue / on_harness_warn
|
|
17
|
+
Using only 'updates' degrades on_token to
|
|
18
|
+
whole-text dumps and misses token streaming entirely.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
29
|
+
|
|
30
|
+
from coderio.agent.harness_middleware import HarnessMiddleware
|
|
31
|
+
from coderio.agent.permission_middleware import PermissionMiddleware
|
|
32
|
+
from coderio.agent.stream import NullStream
|
|
33
|
+
from coderio.session import Message
|
|
34
|
+
from coderio.session.store import Session
|
|
35
|
+
|
|
36
|
+
_log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _content_to_text(content: Any) -> str:
|
|
40
|
+
"""Normalize content (str or list of Anthropic blocks) to text."""
|
|
41
|
+
if isinstance(content, str):
|
|
42
|
+
return content
|
|
43
|
+
if isinstance(content, list):
|
|
44
|
+
return "".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
|
|
45
|
+
return str(content) if content else ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_thinking(content: Any) -> str:
|
|
49
|
+
"""Extract thinking-block text from Anthropic-style content (for the UI)."""
|
|
50
|
+
if not isinstance(content, list):
|
|
51
|
+
return ""
|
|
52
|
+
parts = []
|
|
53
|
+
for block in content:
|
|
54
|
+
if isinstance(block, dict) and block.get("type") == "thinking":
|
|
55
|
+
parts.append(block.get("thinking", "") or block.get("text", ""))
|
|
56
|
+
return "".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _WinLocalShellBackend:
|
|
60
|
+
"""LocalShellBackend subclass that decodes subprocess output lossibly on Windows.
|
|
61
|
+
|
|
62
|
+
deepagents' LocalShellBackend uses subprocess.run(text=True), which decodes
|
|
63
|
+
stdout/stderr as strict UTF-8. On a non-UTF-8 Windows locale (e.g. GBK/CP936),
|
|
64
|
+
shell commands emitting localized bytes (chcp banners, error messages, CJK
|
|
65
|
+
output) raise UnicodeDecodeError and crash the agent. This subclass reads
|
|
66
|
+
bytes and decodes with errors='replace' so the agent keeps running.
|
|
67
|
+
|
|
68
|
+
IMPORTANT: must SUBCLASS LocalShellBackend (not just wrap it) so that
|
|
69
|
+
deepagents' isinstance(backend, SandboxBackendProtocol) check passes —
|
|
70
|
+
composite.py:560 gates the `execute` tool on this check. A composition
|
|
71
|
+
wrapper with __getattr__ proxying fails the isinstance test and the agent
|
|
72
|
+
gets "backend doesn't support command execution" errors.
|
|
73
|
+
|
|
74
|
+
REGRESSION FIX (2026-08-10 sandbox analysis): the previous override read
|
|
75
|
+
`self._root_dir` for cwd, but FilesystemBackend stores the root as
|
|
76
|
+
`self.cwd` (not `_root_dir`). The getattr fallback meant `workspace_root`
|
|
77
|
+
config and the `workdir` arg silently had NO effect on shell execution —
|
|
78
|
+
commands always ran in Path.cwd(). Now reads `self.cwd` (matching upstream
|
|
79
|
+
local_shell.py:335). Also restores stdin=DEVNULL, max_output_bytes
|
|
80
|
+
truncation, and env=self._env that the old override dropped.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Subclass created lazily at instantiation time (deepagents is optional).
|
|
84
|
+
# We can't subclass at module-load time because LocalShellBackend may not
|
|
85
|
+
# be importable. This factory builds a real subclass per instance.
|
|
86
|
+
_RealCls = None
|
|
87
|
+
|
|
88
|
+
def __new__(
|
|
89
|
+
cls, sandbox_mode: str = "off", network_allowed: bool = True, fs_config=None, bash_shell: str = "", **kwargs
|
|
90
|
+
):
|
|
91
|
+
if cls._RealCls is None:
|
|
92
|
+
from deepagents.backends import LocalShellBackend
|
|
93
|
+
|
|
94
|
+
class _Sub(LocalShellBackend):
|
|
95
|
+
"""Real subclass overriding execute for Windows GBK safety.
|
|
96
|
+
|
|
97
|
+
Overrides the upstream execute (local_shell.py:238-384) to read
|
|
98
|
+
bytes and decode with errors='replace'. Preserves upstream's
|
|
99
|
+
cwd=self.cwd, env=self._env, stdin=DEVNULL, max_output_bytes
|
|
100
|
+
truncation, and timeout semantics that the prior coderio
|
|
101
|
+
override had dropped.
|
|
102
|
+
|
|
103
|
+
When sandbox_mode is "job" or "write", delegates to the sandbox
|
|
104
|
+
module (win_sandbox / linux_sandbox) for OS-level isolation
|
|
105
|
+
instead of plain subprocess.run. See config ToolsConfig.sandbox_mode.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# Set per-instance via __init__ below. Default "off" keeps the
|
|
109
|
+
# legacy subprocess path for existing users.
|
|
110
|
+
_sandbox_mode: str = "off"
|
|
111
|
+
# Network policy + filesystem config forwarded to the sandbox
|
|
112
|
+
# runner. Set per-instance so the shell backend can pass them
|
|
113
|
+
# without changing LocalShellBackend's own __init__ signature.
|
|
114
|
+
_network_allowed: bool = True
|
|
115
|
+
_fs_config = None
|
|
116
|
+
# Explicit bash path ([tools].bash_shell config, empty = auto-detect).
|
|
117
|
+
# Windows NEEDS this: shell=True on win32 routes to COMSPEC
|
|
118
|
+
# (cmd.exe), but the system prompt tells the model it's talking
|
|
119
|
+
# to Git Bash — cmd.exe mangles single-quoted args, so
|
|
120
|
+
# python -c 'print(42)' silently returns EMPTY output with
|
|
121
|
+
# exit 0 (2026-08-14 report P0-4: the model thinks the command
|
|
122
|
+
# succeeded and cannot self-correct).
|
|
123
|
+
_bash_shell: str = ""
|
|
124
|
+
|
|
125
|
+
def _resolve_bash(self) -> str | None:
|
|
126
|
+
"""Find a bash executable, or None to fall back to shell=True.
|
|
127
|
+
|
|
128
|
+
Cached at module level after the first successful probe —
|
|
129
|
+
the probe does filesystem checks we don't want per-command.
|
|
130
|
+
"""
|
|
131
|
+
if sys.platform != "win32":
|
|
132
|
+
return None # POSIX shell=True is /bin/sh -c — already correct
|
|
133
|
+
cached = _Sub._bash_cache
|
|
134
|
+
if cached is not None:
|
|
135
|
+
return cached or None
|
|
136
|
+
try:
|
|
137
|
+
from coderio.tools.bash import detect_shell
|
|
138
|
+
|
|
139
|
+
path = detect_shell(getattr(self, "_bash_shell", "") or "")
|
|
140
|
+
_Sub._bash_cache = path
|
|
141
|
+
return path
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
_log.warning(
|
|
144
|
+
"bash not found (Git Bash not installed?) — falling back to "
|
|
145
|
+
"cmd.exe. Single-quoted args and POSIX syntax will misbehave; "
|
|
146
|
+
"install Git Bash or set [tools].bash_shell."
|
|
147
|
+
)
|
|
148
|
+
_Sub._bash_cache = ""
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
_bash_cache: str | None = None
|
|
152
|
+
|
|
153
|
+
def execute(self, command: str, *, timeout: int | None = None): # noqa: ANN201, ARG002
|
|
154
|
+
import subprocess
|
|
155
|
+
|
|
156
|
+
from deepagents.backends.protocol import ExecuteResponse
|
|
157
|
+
|
|
158
|
+
# Sandbox path: delegate to win_sandbox / linux_sandbox when
|
|
159
|
+
# configured. The sandbox module handles cwd/env/timeout/
|
|
160
|
+
# truncation internally, so we skip the subprocess block below.
|
|
161
|
+
mode = getattr(self, "_sandbox_mode", "off")
|
|
162
|
+
if mode in ("job", "write"):
|
|
163
|
+
from coderio.tools.sandbox_runner import run_with_sandbox
|
|
164
|
+
|
|
165
|
+
cwd_val = getattr(self, "cwd", None) or Path.cwd()
|
|
166
|
+
cwd_str = str(cwd_val)
|
|
167
|
+
# Workspace existence check: CreateProcessAsUserW (Win) and
|
|
168
|
+
# bwrap (Linux) both fail opaquely when cwd doesn't exist
|
|
169
|
+
# (Win: "CreateProcessAsUserW failed err=0"; bwrap: cryptic
|
|
170
|
+
# mount error). A clear error here lets the model understand
|
|
171
|
+
# the root cause (misconfigured workspace_root) and surface
|
|
172
|
+
# it to the user, instead of a chain of mystery failures.
|
|
173
|
+
# We check self.cwd (a Path) rather than the resolved string
|
|
174
|
+
# so symlinked paths still pass.
|
|
175
|
+
if not Path(cwd_str).is_dir():
|
|
176
|
+
return ExecuteResponse(
|
|
177
|
+
output=(
|
|
178
|
+
f"Error: workspace_root points to a non-existent "
|
|
179
|
+
f"directory: {cwd_str}. Update [tools].workspace_root "
|
|
180
|
+
f"in config.toml to point to your project directory, "
|
|
181
|
+
f"or leave it empty to use the current working directory."
|
|
182
|
+
),
|
|
183
|
+
exit_code=1,
|
|
184
|
+
)
|
|
185
|
+
exit_code, output = run_with_sandbox(
|
|
186
|
+
command,
|
|
187
|
+
cwd=cwd_str,
|
|
188
|
+
mode=mode,
|
|
189
|
+
timeout=timeout or 120,
|
|
190
|
+
env=getattr(self, "_env", None),
|
|
191
|
+
network_allowed=getattr(self, "_network_allowed", True),
|
|
192
|
+
fs_config=getattr(self, "_fs_config", None),
|
|
193
|
+
)
|
|
194
|
+
return ExecuteResponse(output=output, exit_code=exit_code)
|
|
195
|
+
|
|
196
|
+
# Plain subprocess path (sandbox_mode="off" or sandbox failure).
|
|
197
|
+
cwd = str(getattr(self, "cwd", None) or Path.cwd())
|
|
198
|
+
effective_timeout = timeout if timeout is not None else getattr(self, "_default_timeout", 120)
|
|
199
|
+
max_output = getattr(self, "_max_output_bytes", 100_000)
|
|
200
|
+
env = getattr(self, "_env", None)
|
|
201
|
+
# Windows: run through Git Bash explicitly ([bash, '-c', cmd])
|
|
202
|
+
# instead of shell=True→cmd.exe. cmd.exe doesn't process single
|
|
203
|
+
# quotes, so `python -c 'print(42)'` returned EMPTY output with
|
|
204
|
+
# exit 0 — the model believed broken commands succeeded
|
|
205
|
+
# (2026-08-14 report P0-4). POSIX keeps shell=True (/bin/sh -c,
|
|
206
|
+
# semantically identical to bash -c for our purposes).
|
|
207
|
+
bash_path = self._resolve_bash()
|
|
208
|
+
run_args = [bash_path, "-c", command] if bash_path else command
|
|
209
|
+
try:
|
|
210
|
+
proc = subprocess.run(
|
|
211
|
+
run_args,
|
|
212
|
+
shell=(bash_path is None),
|
|
213
|
+
capture_output=True,
|
|
214
|
+
cwd=cwd,
|
|
215
|
+
timeout=effective_timeout,
|
|
216
|
+
text=False, # bytes — decode ourselves (Windows GBK safety)
|
|
217
|
+
stdin=subprocess.DEVNULL, # prevent stdin-reading cmds from hanging
|
|
218
|
+
env=env,
|
|
219
|
+
)
|
|
220
|
+
stdout = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
|
|
221
|
+
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
|
222
|
+
exit_code = proc.returncode
|
|
223
|
+
except subprocess.TimeoutExpired:
|
|
224
|
+
return ExecuteResponse(output=f"Command timed out after {effective_timeout}s", exit_code=124)
|
|
225
|
+
except Exception as e: # noqa: BLE001
|
|
226
|
+
return ExecuteResponse(output=f"Execution error: {e}", exit_code=1)
|
|
227
|
+
output = stdout
|
|
228
|
+
if stderr:
|
|
229
|
+
output += f"\n[stderr]\n{stderr}"
|
|
230
|
+
# Truncate oversized output (restores upstream behavior that
|
|
231
|
+
# the old override dropped — without this, a `find /` or
|
|
232
|
+
# verbose build log can OOM the agent's context window).
|
|
233
|
+
if len(output) > max_output:
|
|
234
|
+
output = output[:max_output] + f"\n\n... Output truncated at {max_output} bytes."
|
|
235
|
+
return ExecuteResponse(output=output, exit_code=exit_code)
|
|
236
|
+
|
|
237
|
+
cls._RealCls = _Sub
|
|
238
|
+
inst = cls._RealCls(**kwargs)
|
|
239
|
+
inst._sandbox_mode = sandbox_mode
|
|
240
|
+
inst._network_allowed = network_allowed
|
|
241
|
+
inst._fs_config = fs_config
|
|
242
|
+
inst._bash_shell = bash_shell
|
|
243
|
+
return inst
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolve_system_prompt(system_prompt, skill_store, active_skills):
|
|
247
|
+
"""Build coderio's system prompt, adapting tool names for deepagents."""
|
|
248
|
+
if system_prompt is not None:
|
|
249
|
+
return system_prompt
|
|
250
|
+
from coderio.agent.prompts import ActiveSkills, build_system_prompt
|
|
251
|
+
from coderio.skills.store import SkillStore
|
|
252
|
+
|
|
253
|
+
store = skill_store or SkillStore()
|
|
254
|
+
active = active_skills or ActiveSkills()
|
|
255
|
+
sp = build_system_prompt(store, active)
|
|
256
|
+
# deepagents' shell tool is named 'execute', not 'bash'. The system prompt
|
|
257
|
+
# references 'bash' (coderio's original name); translate the standalone word
|
|
258
|
+
# so the model calls the right tool. Word-boundary regex is robust to prose
|
|
259
|
+
# changes (same pattern as _BASH_TO_EXECUTE in harness_middleware.py).
|
|
260
|
+
import re
|
|
261
|
+
|
|
262
|
+
return re.sub(r"\bbash\b", "execute", sp)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _build_extra_tools(tools, skill_store, active_skills):
|
|
266
|
+
"""Collect coderio tools not already provided by deepagents."""
|
|
267
|
+
from coderio.tools.base import to_langchain_tool as _adapt
|
|
268
|
+
|
|
269
|
+
# These are CODERIO's own tool names (bash, todo, list_dir — the old ReAct
|
|
270
|
+
# names), NOT deepagents' names (execute, write_todos, ls). We skip them
|
|
271
|
+
# because deepagents already provides equivalents with its own naming.
|
|
272
|
+
# The name translation between the two namespaces lives in
|
|
273
|
+
# harness_middleware._to_coderio_name and _BASH_TO_EXECUTE.
|
|
274
|
+
_SKIP = frozenset({"read_file", "write_file", "edit_file", "glob", "grep", "bash", "todo", "list_dir"})
|
|
275
|
+
extra: list = []
|
|
276
|
+
if tools:
|
|
277
|
+
for t in tools:
|
|
278
|
+
# MCP tools arrive as langchain BaseTool/StructuredTool (they have
|
|
279
|
+
# `invoke` but not coderio Tool's plain `run`). Pass through
|
|
280
|
+
# without re-adapting — their names are prefixed (e.g.
|
|
281
|
+
# "filesystem_read_file") so they never collide with _SKIP entries.
|
|
282
|
+
if hasattr(t, "invoke") and not hasattr(t, "args_schema"):
|
|
283
|
+
extra.append(t)
|
|
284
|
+
continue
|
|
285
|
+
name = getattr(t, "name", "")
|
|
286
|
+
if name in _SKIP:
|
|
287
|
+
continue
|
|
288
|
+
schema = getattr(t, "args_schema", None)
|
|
289
|
+
if schema is not None:
|
|
290
|
+
extra.append(_adapt(t, schema))
|
|
291
|
+
if skill_store is not None and active_skills is not None:
|
|
292
|
+
from coderio.agent.skill_tool import ActivateSkillTool, DeactivateSkillTool
|
|
293
|
+
|
|
294
|
+
act = ActivateSkillTool(skill_store, active_skills)
|
|
295
|
+
deact = DeactivateSkillTool(active_skills)
|
|
296
|
+
extra.append(_adapt(act, act.args_schema))
|
|
297
|
+
extra.append(_adapt(deact, deact.args_schema))
|
|
298
|
+
return extra
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _build_research_subagent():
|
|
302
|
+
"""Return the research subagent spec (read-only, physically isolated).
|
|
303
|
+
|
|
304
|
+
Tool exclusion uses the compat layer (_deepagents_compat) so that a
|
|
305
|
+
deepagents API change degrades gracefully instead of crashing.
|
|
306
|
+
"""
|
|
307
|
+
from coderio.agent._deepagents_compat import make_research_subagent_middleware
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"name": "research",
|
|
311
|
+
"description": (
|
|
312
|
+
"Read-only research and analysis agent. Use for: exploring an "
|
|
313
|
+
"unfamiliar codebase section, finding where a feature is "
|
|
314
|
+
"implemented, summarizing a file's purpose, or gathering "
|
|
315
|
+
"evidence to ground an analysis. This agent can read files "
|
|
316
|
+
"and search but CANNOT write or execute — it returns findings "
|
|
317
|
+
"as text. Use it when you need to read many files without "
|
|
318
|
+
"cluttering your own context."
|
|
319
|
+
),
|
|
320
|
+
"system_prompt": (
|
|
321
|
+
"You are a research subagent. Your job is to investigate the "
|
|
322
|
+
"codebase and return clear, grounded findings.\n\n"
|
|
323
|
+
"Rules:\n"
|
|
324
|
+
"- Read the relevant files thoroughly before concluding.\n"
|
|
325
|
+
"- Quote specific lines/functions as evidence.\n"
|
|
326
|
+
"- Separate what you verified by reading from what you infer.\n"
|
|
327
|
+
"- Be concise: return only the findings the caller needs, not "
|
|
328
|
+
"a full retelling of every file you read.\n"
|
|
329
|
+
"- If you can't find something, say so explicitly.\n"
|
|
330
|
+
"- The calling agent only sees your final message, not your "
|
|
331
|
+
"intermediate tool calls — make sure your answer is complete."
|
|
332
|
+
),
|
|
333
|
+
"middleware": make_research_subagent_middleware(),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _build_general_purpose_subagent(gate, command_policy, stream=None):
|
|
338
|
+
"""Return the general-purpose subagent spec with coderio's security middleware.
|
|
339
|
+
|
|
340
|
+
SECURITY FIX (2026-08-14 report P0-2): deepagents auto-injects a
|
|
341
|
+
``general-purpose`` subagent when the caller doesn't provide one
|
|
342
|
+
(graph.py:711-770), with a HARDCODED middleware list that does NOT include
|
|
343
|
+
coderio's PermissionMiddleware / CommandReviewMiddleware / HarnessMiddleware.
|
|
344
|
+
That subagent gets execute + write_file + edit_file via FilesystemMiddleware
|
|
345
|
+
and shares the main backend — so ``task(subagent_type="general-purpose")``
|
|
346
|
+
bypassed ALL of coderio's security layers. In PLAN mode (nominally
|
|
347
|
+
read-only), a prompt-injected model could delegate arbitrary shell + file
|
|
348
|
+
writes to this subagent unchecked.
|
|
349
|
+
|
|
350
|
+
Fix: provide an EXPLICIT spec with the same name ("general-purpose") —
|
|
351
|
+
deepagents skips its auto-injection when a same-named spec exists — and
|
|
352
|
+
inject coderio's middleware stack so every tool call inside the subagent
|
|
353
|
+
goes through the same permission gate + command review as the main agent.
|
|
354
|
+
|
|
355
|
+
2026-08-14 v2 audit follow-up: also inject HarnessMiddleware — WITHOUT it,
|
|
356
|
+
the model could delegate "write the code" to this subagent and then claim
|
|
357
|
+
completion in the main agent; the subagent's writes were invisible to the
|
|
358
|
+
main VerifyGate. The subagent gets its OWN Harness instance (harness state
|
|
359
|
+
is per-agent, not shared — the main gate can't see subagent tool calls
|
|
360
|
+
anyway), whose force-continue applies inside the subagent's loop. This
|
|
361
|
+
makes the subagent itself refuse to say "done" with unverified writes.
|
|
362
|
+
"""
|
|
363
|
+
from coderio.agent.command_review import CommandReviewMiddleware
|
|
364
|
+
from coderio.agent.harness_middleware import HarnessMiddleware
|
|
365
|
+
from coderio.tools.command_policy import CommandPolicy
|
|
366
|
+
|
|
367
|
+
middleware = []
|
|
368
|
+
# Harness first (same order as the main agent — see run_deep_agent): it
|
|
369
|
+
# observes every tool call the permission/command layers let through.
|
|
370
|
+
middleware.append(HarnessMiddleware(stream=stream))
|
|
371
|
+
if gate is not None:
|
|
372
|
+
from coderio.agent.permission_middleware import PermissionMiddleware
|
|
373
|
+
|
|
374
|
+
middleware.append(PermissionMiddleware(gate))
|
|
375
|
+
policy = command_policy or CommandPolicy.default()
|
|
376
|
+
middleware.append(CommandReviewMiddleware(policy, gate=gate))
|
|
377
|
+
return {
|
|
378
|
+
"name": "general-purpose",
|
|
379
|
+
"description": (
|
|
380
|
+
"General-purpose subagent with full tool access. coderio's "
|
|
381
|
+
"permission gate and command review apply to every tool call "
|
|
382
|
+
"inside this agent, same as the main agent."
|
|
383
|
+
),
|
|
384
|
+
"system_prompt": (
|
|
385
|
+
"You are a general-purpose subagent. Complete the task you were "
|
|
386
|
+
"given and return a clear, complete result. The calling agent "
|
|
387
|
+
"only sees your final message, not intermediate tool calls."
|
|
388
|
+
),
|
|
389
|
+
"middleware": middleware,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _build_inputs(checkpointer, user_input: str, session: Session) -> dict:
|
|
394
|
+
"""Build the messages input for the agent stream.
|
|
395
|
+
|
|
396
|
+
With a checkpointer: only pass the new user message (deepagents restores
|
|
397
|
+
prior state from sqlite). Without: pass full conversation history.
|
|
398
|
+
"""
|
|
399
|
+
if checkpointer is not None:
|
|
400
|
+
return {"messages": [HumanMessage(content=user_input)]}
|
|
401
|
+
return {"messages": _build_history_messages(session.messages)}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _run_stream(agent, inputs, thread_id, recursion_limit, stream, session, seen_ids, turn_writes):
|
|
405
|
+
"""Drive the deepagents graph with three stream modes. Returns final text."""
|
|
406
|
+
config = {
|
|
407
|
+
"recursion_limit": recursion_limit,
|
|
408
|
+
"configurable": {"thread_id": thread_id},
|
|
409
|
+
}
|
|
410
|
+
tc_args: dict[str, tuple] = {} # tool_call_id → (name, args) for file path tracking
|
|
411
|
+
final_text = ""
|
|
412
|
+
for mode, event in agent.stream(inputs, config=config, stream_mode=["messages", "updates", "custom"]):
|
|
413
|
+
if mode == "messages":
|
|
414
|
+
_handle_messages_mode(event, stream, session)
|
|
415
|
+
elif mode == "updates":
|
|
416
|
+
final_text = _handle_updates_mode(event, stream, session, seen_ids, turn_writes, tc_args) or final_text
|
|
417
|
+
elif mode == "custom":
|
|
418
|
+
_handle_custom_mode(event, stream)
|
|
419
|
+
return final_text
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def run_deep_agent(
|
|
423
|
+
user_input: str,
|
|
424
|
+
model,
|
|
425
|
+
session: Session,
|
|
426
|
+
stream=None,
|
|
427
|
+
*,
|
|
428
|
+
gate=None,
|
|
429
|
+
skill_store=None,
|
|
430
|
+
active_skills=None,
|
|
431
|
+
tools: list | None = None,
|
|
432
|
+
system_prompt: str | None = None,
|
|
433
|
+
workdir: str | Path | None = None,
|
|
434
|
+
harness_enabled: bool = True,
|
|
435
|
+
recursion_limit: int = 200,
|
|
436
|
+
command_policy=None,
|
|
437
|
+
sandbox_mode: str = "off",
|
|
438
|
+
network_allowed: bool = True,
|
|
439
|
+
fs_config=None,
|
|
440
|
+
bash_shell: str = "",
|
|
441
|
+
) -> str:
|
|
442
|
+
"""Run a deepagents-backed agent turn (coderio's production engine).
|
|
443
|
+
|
|
444
|
+
Builds a create_deep_agent with coderio's middleware stack (harness +
|
|
445
|
+
permission), a Windows-safe shell backend, and coderio's extra tools.
|
|
446
|
+
Streams events to coderio's StreamHandler protocol via three stream modes.
|
|
447
|
+
Returns the final assistant text (also persisted to the session).
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
user_input: the user's message (str or multimodal content-block list).
|
|
451
|
+
model: a langchain BaseChatModel.
|
|
452
|
+
session: coderio Session (messages persisted here).
|
|
453
|
+
stream: coderio StreamHandler (NullStream if None).
|
|
454
|
+
gate: coderio PermissionGate (wrapped in PermissionMiddleware). None = no gate.
|
|
455
|
+
skill_store: SkillStore (for build_system_prompt). None = empty store.
|
|
456
|
+
active_skills: ActiveSkills (for build_system_prompt). None = empty.
|
|
457
|
+
tools: extra coderio tools beyond deepagents' built-in FS/shell set.
|
|
458
|
+
system_prompt: optional override (defaults to coderio's build_system_prompt).
|
|
459
|
+
workdir: root dir for the shell backend (defaults to CWD).
|
|
460
|
+
harness_enabled: if False, the verification harness is disabled.
|
|
461
|
+
recursion_limit: langgraph recursion limit. Harness force-continues and
|
|
462
|
+
middleware hooks each consume recursion budget; 200 is a safe default
|
|
463
|
+
(harness escalates after 2 force-continues, each round uses ~5-10 recursions).
|
|
464
|
+
command_policy: a CommandPolicy for the command-review middleware (blocks
|
|
465
|
+
destructive shell commands like rm -rf /, mkfs, fork bombs, and
|
|
466
|
+
optionally disables web tools). None = use CommandPolicy.default()
|
|
467
|
+
(built-in blacklist active, network allowed). Pass an explicit policy
|
|
468
|
+
to customize via config.toml [tools].blocked_commands / network_allowed.
|
|
469
|
+
"""
|
|
470
|
+
stream = stream or NullStream()
|
|
471
|
+
from deepagents import create_deep_agent
|
|
472
|
+
|
|
473
|
+
# Neutralize deepagents' BASE_AGENT_PROMPT via compat layer (graceful
|
|
474
|
+
# degradation if the internal API changes).
|
|
475
|
+
from coderio.agent._deepagents_compat import neutralize_base_prompt
|
|
476
|
+
|
|
477
|
+
neutralize_base_prompt()
|
|
478
|
+
|
|
479
|
+
session.append(Message.user(user_input))
|
|
480
|
+
|
|
481
|
+
sp = _resolve_system_prompt(system_prompt, skill_store, active_skills)
|
|
482
|
+
middleware = [HarnessMiddleware(stream=stream, enabled=harness_enabled)]
|
|
483
|
+
if gate is not None:
|
|
484
|
+
middleware.append(PermissionMiddleware(gate))
|
|
485
|
+
# Command-content review: always active (even in FULL mode). Blocks rm -rf /,
|
|
486
|
+
# mkfs, fork bombs, etc. before they reach subprocess.run(shell=True).
|
|
487
|
+
# This is NOT a real OS sandbox — see command_policy.py for limitations.
|
|
488
|
+
# The gate reference lets the whitelist (if enabled) degrade based on mode
|
|
489
|
+
# (FULL allows, others prompt) rather than hard-blocking unknown commands.
|
|
490
|
+
from coderio.agent.command_review import CommandReviewMiddleware
|
|
491
|
+
from coderio.tools.command_policy import CommandPolicy
|
|
492
|
+
|
|
493
|
+
policy = command_policy or CommandPolicy.default()
|
|
494
|
+
middleware.append(CommandReviewMiddleware(policy, gate=gate))
|
|
495
|
+
|
|
496
|
+
backend = _WinLocalShellBackend(
|
|
497
|
+
root_dir=str(workdir or Path.cwd()),
|
|
498
|
+
virtual_mode=True,
|
|
499
|
+
inherit_env=True,
|
|
500
|
+
sandbox_mode=sandbox_mode,
|
|
501
|
+
network_allowed=network_allowed,
|
|
502
|
+
fs_config=fs_config,
|
|
503
|
+
bash_shell=bash_shell,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
extra_lc_tools = _build_extra_tools(tools, skill_store, active_skills)
|
|
507
|
+
|
|
508
|
+
build_kwargs: dict[str, Any] = {
|
|
509
|
+
"model": model,
|
|
510
|
+
"middleware": middleware,
|
|
511
|
+
"backend": backend,
|
|
512
|
+
"subagents": [
|
|
513
|
+
_build_research_subagent(),
|
|
514
|
+
_build_general_purpose_subagent(gate, command_policy, stream=stream),
|
|
515
|
+
],
|
|
516
|
+
}
|
|
517
|
+
if sp:
|
|
518
|
+
build_kwargs["system_prompt"] = sp
|
|
519
|
+
if extra_lc_tools:
|
|
520
|
+
build_kwargs["tools"] = extra_lc_tools
|
|
521
|
+
|
|
522
|
+
# --- Checkpointer: persist graph state across turns (sqlite) ---
|
|
523
|
+
# Without a checkpointer, each run_deep_agent call starts from scratch —
|
|
524
|
+
# SummarizationMiddleware's accumulated state resets, and we'd have to
|
|
525
|
+
# re-pass the full history every turn (expensive + grows linearly).
|
|
526
|
+
# With a checkpointer, deepagents restores prior state from the sqlite DB
|
|
527
|
+
# and we only pass the NEW user message. Falls back to full-history mode
|
|
528
|
+
# if sqlite is unavailable (package missing or DB corrupted).
|
|
529
|
+
thread_id = session.id
|
|
530
|
+
checkpointer, _db_conn = _try_create_checkpointer(session)
|
|
531
|
+
if checkpointer is not None:
|
|
532
|
+
build_kwargs["checkpointer"] = checkpointer
|
|
533
|
+
|
|
534
|
+
final_text = ""
|
|
535
|
+
_seen_tool_calls: set[str] = set()
|
|
536
|
+
_turn_writes: list[str] = []
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
agent = create_deep_agent(**build_kwargs)
|
|
540
|
+
inputs = _build_inputs(checkpointer, user_input, session)
|
|
541
|
+
if hasattr(stream, "on_step_start"):
|
|
542
|
+
stream.on_step_start()
|
|
543
|
+
final_text = _run_stream(
|
|
544
|
+
agent, inputs, thread_id, recursion_limit, stream, session, _seen_tool_calls, _turn_writes
|
|
545
|
+
)
|
|
546
|
+
finally:
|
|
547
|
+
if _db_conn is not None:
|
|
548
|
+
try:
|
|
549
|
+
_db_conn.close()
|
|
550
|
+
except Exception: # noqa: S110
|
|
551
|
+
pass
|
|
552
|
+
|
|
553
|
+
if hasattr(stream, "on_finish"):
|
|
554
|
+
stream.on_finish()
|
|
555
|
+
if hasattr(stream, "on_turn_end"):
|
|
556
|
+
stream.on_turn_end(_turn_writes)
|
|
557
|
+
if final_text and not _final_already_persisted(session):
|
|
558
|
+
session.append(Message.assistant(final_text))
|
|
559
|
+
return final_text
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _final_already_persisted(session: Session) -> bool:
|
|
563
|
+
"""Check if the last message is already an assistant text (avoid double-append).
|
|
564
|
+
|
|
565
|
+
_handle_updates_mode persists AIMessages as they arrive; the final text may
|
|
566
|
+
already be the last session message. We avoid appending a duplicate.
|
|
567
|
+
"""
|
|
568
|
+
msgs = session.messages
|
|
569
|
+
if not msgs:
|
|
570
|
+
return False
|
|
571
|
+
last = msgs[-1]
|
|
572
|
+
return last.role == "assistant" and not getattr(last, "tool_calls", None)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _try_create_checkpointer(session: Session):
|
|
576
|
+
"""Create a SqliteSaver for graph state persistence.
|
|
577
|
+
|
|
578
|
+
Returns (checkpointer, conn) or (None, None). The caller MUST close the
|
|
579
|
+
conn after use to avoid leaking file handles (especially on Windows where
|
|
580
|
+
open sqlite files can't be deleted).
|
|
581
|
+
|
|
582
|
+
Stores checkpoint state alongside the session jsonl (same directory,
|
|
583
|
+
{session_id}.sqlite).
|
|
584
|
+
"""
|
|
585
|
+
try:
|
|
586
|
+
import sqlite3
|
|
587
|
+
|
|
588
|
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
589
|
+
|
|
590
|
+
db_path = session.path.parent / f"{session.id}.sqlite"
|
|
591
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
592
|
+
except ImportError:
|
|
593
|
+
return None, None
|
|
594
|
+
except Exception:
|
|
595
|
+
return None, None
|
|
596
|
+
# conn is now open. If SqliteSaver() or setup() fails, close it.
|
|
597
|
+
try:
|
|
598
|
+
checkpointer = SqliteSaver(conn)
|
|
599
|
+
checkpointer.setup()
|
|
600
|
+
return checkpointer, conn
|
|
601
|
+
except Exception:
|
|
602
|
+
try:
|
|
603
|
+
conn.close()
|
|
604
|
+
except Exception: # noqa: S110
|
|
605
|
+
pass
|
|
606
|
+
return None, None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _build_history_messages(session_messages: list) -> list:
|
|
610
|
+
"""Convert session messages to langchain messages for deepagents input.
|
|
611
|
+
|
|
612
|
+
Unlike _to_langchain_messages (which prepends a SystemMessage), this only
|
|
613
|
+
returns user/assistant/tool messages — the system prompt is injected
|
|
614
|
+
separately by create_deep_agent's system_prompt parameter.
|
|
615
|
+
|
|
616
|
+
Drops phase_timeline system messages (observability metadata). Keeps
|
|
617
|
+
context_summary system messages (they carry compacted history the model
|
|
618
|
+
needs). The current turn's user message is the LAST element (already
|
|
619
|
+
appended to session before this call).
|
|
620
|
+
"""
|
|
621
|
+
msgs: list = []
|
|
622
|
+
for m in session_messages:
|
|
623
|
+
if m.role == "user":
|
|
624
|
+
msgs.append(HumanMessage(content=m.content))
|
|
625
|
+
elif m.role == "assistant":
|
|
626
|
+
tcs = None
|
|
627
|
+
if m.tool_calls:
|
|
628
|
+
tcs = [{"name": tc.name, "args": tc.args, "id": tc.id, "type": "tool_call"} for tc in m.tool_calls]
|
|
629
|
+
msgs.append(AIMessage(content=m.content, tool_calls=tcs or []))
|
|
630
|
+
elif m.role == "tool":
|
|
631
|
+
msgs.append(ToolMessage(content=m.content, tool_call_id=m.tool_call_id or ""))
|
|
632
|
+
elif m.role == "system":
|
|
633
|
+
# Phase timelines are metadata — never shown to the model.
|
|
634
|
+
# Context summaries ARE shown (that's their purpose).
|
|
635
|
+
if getattr(m, "kind", None) != "phase_timeline":
|
|
636
|
+
msgs.append(HumanMessage(content=m.content))
|
|
637
|
+
return msgs
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _handle_messages_mode(event, stream, session) -> None:
|
|
641
|
+
"""Process 'messages' mode: token-by-token streaming.
|
|
642
|
+
|
|
643
|
+
event is (AIMessageChunk, metadata). We extract text → on_token and
|
|
644
|
+
thinking blocks → on_thinking. We do NOT persist here — complete messages
|
|
645
|
+
are persisted in the 'updates' mode handler.
|
|
646
|
+
"""
|
|
647
|
+
if not isinstance(event, tuple) or len(event) != 2:
|
|
648
|
+
return
|
|
649
|
+
chunk, metadata = event
|
|
650
|
+
if not isinstance(chunk, AIMessage):
|
|
651
|
+
return
|
|
652
|
+
# Only stream from the model node (not tool results).
|
|
653
|
+
node = metadata.get("langgraph_node", "") if isinstance(metadata, dict) else ""
|
|
654
|
+
if node and node not in ("model", ""):
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
raw = getattr(chunk, "content", "")
|
|
658
|
+
# Thinking blocks (Anthropic).
|
|
659
|
+
thinking = _extract_thinking(raw)
|
|
660
|
+
if thinking and hasattr(stream, "on_thinking"):
|
|
661
|
+
stream.on_thinking(thinking)
|
|
662
|
+
# Text content.
|
|
663
|
+
text = _content_to_text(raw)
|
|
664
|
+
if text and hasattr(stream, "on_token"):
|
|
665
|
+
stream.on_token(text)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _handle_updates_mode(event, stream, session, seen_ids: set, turn_writes: list, tc_args: dict) -> str:
|
|
669
|
+
"""Process 'updates' mode: complete messages (tool calls, tool results, final text).
|
|
670
|
+
|
|
671
|
+
Returns the final assistant text if this event carries it (for the caller to
|
|
672
|
+
return). Persists messages to session.
|
|
673
|
+
"""
|
|
674
|
+
if not isinstance(event, dict):
|
|
675
|
+
return ""
|
|
676
|
+
final_text = ""
|
|
677
|
+
for _node, payload in event.items():
|
|
678
|
+
if not isinstance(payload, dict):
|
|
679
|
+
continue
|
|
680
|
+
msgs = payload.get("messages", [])
|
|
681
|
+
for m in msgs:
|
|
682
|
+
final_text = _emit_message(m, stream, session, seen_ids, turn_writes, tc_args) or final_text
|
|
683
|
+
return final_text
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _handle_custom_mode(event, stream) -> None:
|
|
687
|
+
"""Process 'custom' mode: harness signals (continue / warn).
|
|
688
|
+
|
|
689
|
+
event is the dict passed to runtime.stream_writer by HarnessMiddleware.
|
|
690
|
+
"""
|
|
691
|
+
if not isinstance(event, dict):
|
|
692
|
+
return
|
|
693
|
+
etype = event.get("type")
|
|
694
|
+
if etype == "harness_continue" and hasattr(stream, "on_harness_continue"):
|
|
695
|
+
stream.on_harness_continue(event.get("reason", ""))
|
|
696
|
+
elif etype == "harness_warn" and hasattr(stream, "on_harness_warn"):
|
|
697
|
+
stream.on_harness_warn(event.get("message", ""))
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _emit_message(m, stream, session, seen_ids: set, turn_writes: list, tc_args: dict | None = None) -> str:
|
|
701
|
+
"""Map a complete langchain message to stream callbacks + session persistence.
|
|
702
|
+
|
|
703
|
+
Returns the assistant text if this is a final (no tool_calls) AIMessage.
|
|
704
|
+
"""
|
|
705
|
+
if isinstance(m, AIMessage):
|
|
706
|
+
text = _content_to_text(getattr(m, "content", ""))
|
|
707
|
+
tool_calls = getattr(m, "tool_calls", None) or []
|
|
708
|
+
# Usage metadata lives on the AIMessage object (not in the updates
|
|
709
|
+
# payload dict). Extract it here so the status bar can show live token
|
|
710
|
+
# consumption. Without this, add_usage is never called (the old code
|
|
711
|
+
# looked for it in payload.get("usage_metadata") which is always empty).
|
|
712
|
+
usage = getattr(m, "usage_metadata", None)
|
|
713
|
+
if usage and hasattr(stream, "add_usage"):
|
|
714
|
+
stream.add_usage(usage)
|
|
715
|
+
if tool_calls:
|
|
716
|
+
from coderio.session import ToolCall
|
|
717
|
+
|
|
718
|
+
tcs = []
|
|
719
|
+
for tc in tool_calls:
|
|
720
|
+
name = tc.get("name", "") if isinstance(tc, dict) else getattr(tc, "name", "")
|
|
721
|
+
args = dict(tc.get("args", {})) if isinstance(tc, dict) else dict(getattr(tc, "args", {}))
|
|
722
|
+
tc_id = tc.get("id", "") if isinstance(tc, dict) else getattr(tc, "id", "")
|
|
723
|
+
if tc_id not in seen_ids:
|
|
724
|
+
if hasattr(stream, "on_tool_start"):
|
|
725
|
+
stream.on_tool_start(name, args)
|
|
726
|
+
seen_ids.add(tc_id)
|
|
727
|
+
# Intercept write_todos: deepagents replaces the whole list each
|
|
728
|
+
# call, so args["todos"] is the current full todo list. Push it
|
|
729
|
+
# to the UI for the live todo panel.
|
|
730
|
+
if name == "write_todos" and "todos" in args and hasattr(stream, "on_todos_update"):
|
|
731
|
+
stream.on_todos_update(args["todos"])
|
|
732
|
+
tcs.append(ToolCall(id=tc_id, name=name, args=args))
|
|
733
|
+
# Remember args by tool_call_id so ToolMessage handler can
|
|
734
|
+
# extract file paths for the turn-end summary.
|
|
735
|
+
if tc_args is not None:
|
|
736
|
+
tc_args[tc_id] = (name, args)
|
|
737
|
+
session.append(Message.assistant(text, tool_calls=tcs))
|
|
738
|
+
return ""
|
|
739
|
+
elif text:
|
|
740
|
+
session.append(Message.assistant(text))
|
|
741
|
+
return text
|
|
742
|
+
elif isinstance(m, ToolMessage) and getattr(m, "tool_call_id", None):
|
|
743
|
+
name = getattr(m, "name", "tool")
|
|
744
|
+
content = _content_to_text(getattr(m, "content", ""))
|
|
745
|
+
if hasattr(stream, "on_tool_end"):
|
|
746
|
+
stream.on_tool_end(name, content)
|
|
747
|
+
session.append(Message.tool_result(m.tool_call_id, name, content))
|
|
748
|
+
# Track file writes for the turn-end summary. Use tc_args to get the
|
|
749
|
+
# actual file path (ToolMessage only has content, not args).
|
|
750
|
+
if name in ("write_file", "edit_file") and not content.startswith(("Error", "Permission denied")):
|
|
751
|
+
file_path = ""
|
|
752
|
+
if tc_args is not None:
|
|
753
|
+
_stored = tc_args.get(m.tool_call_id)
|
|
754
|
+
if _stored:
|
|
755
|
+
_sname, _sargs = _stored
|
|
756
|
+
file_path = str(_sargs.get("file_path", _sargs.get("path", "")))
|
|
757
|
+
turn_writes.append(file_path or name)
|
|
758
|
+
return ""
|