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.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. 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 ""