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,293 @@
1
+ """Harness as a deepagents AgentMiddleware.
2
+
3
+ This ports coderio's structural harness (the "wrote code but never verified →
4
+ block done" hard constraint) into deepagents' middleware layer. The existing
5
+ Harness/HarnessState logic in agent/harness.py is reused unchanged; this module
6
+ is the adapter that wires it into deepagents' after_model / wrap_tool_call hooks.
7
+
8
+ Why this exists: deepagents is a batteries-included harness (planning tool,
9
+ filesystem, subagents, context management) but it does NOT enforce verification
10
+ before "done" — it trusts the agent. coderio's harness is the one structural
11
+ constraint that must survive the migration. As a middleware, it intercepts:
12
+ - wrap_tool_call: observe writes/executions (ground truth) + nudge (PlanGate)
13
+ - after_model: decide whether the model's "no tool_calls" (want-to-end) is
14
+ allowed, or force-continue via jump_to='model' (VerifyGate)
15
+
16
+ CRITICAL: after_model MUST be decorated with @hook_config(can_jump_to=["model"]).
17
+ Without it, langchain's factory sees can_jump_to=[] and does NOT build the
18
+ conditional edge for this middleware, so jump_to='model' silently does nothing —
19
+ the agent ends despite the harness wanting to force-continue. This was a latent
20
+ bug in the original version (verified against langchain factory.py
21
+ _get_can_jump_to, which reads method.__can_jump_to__).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from typing import Any
28
+
29
+ from langchain.agents.middleware.types import AgentMiddleware, hook_config
30
+ from langchain_core.messages import AIMessage, HumanMessage
31
+
32
+ from coderio.agent._deepagents_compat import get_state_todos
33
+ from coderio.agent.harness import Harness, HarnessState
34
+ from coderio.tools.todo import TodoStore
35
+
36
+ # deepagents tool-name mapping. deepagents uses 'execute' for shell (coderio
37
+ # used 'bash'), and write_file/edit_file (no multi_edit). We translate so the
38
+ # existing Harness logic works without modification.
39
+ _DEEP_VERIFY_TOOL = "execute"
40
+ _DEEP_WRITE_TOOLS = frozenset({"write_file", "edit_file"})
41
+ # deepagents' planning tool is 'write_todos' (coderio used 'todo'). The
42
+ # CompletionGate checks for pending todos — map deepagents' todo tool too.
43
+ _DEEP_TODO_TOOL = "write_todos"
44
+
45
+ # Translate "bash" → "execute" in harness injection prose. Word-boundary regex
46
+ # (robust to prose changes) instead of a chain of literal .replace() calls that
47
+ # silently no-op when harness.py rewords a phrase.
48
+ _BASH_TO_EXECUTE = re.compile(r"\bbash\b", re.IGNORECASE)
49
+
50
+
51
+ def _stream_supports_phase(stream: Any) -> bool:
52
+ """Does this stream consumer actually display phase changes?
53
+
54
+ Avoids paying AgentStateTracker overhead (and polluting the session jsonl
55
+ timeline) when no one is watching: NullStream (headless tests) and ad-hoc
56
+ stubs without on_phase_change return False. The real TUI StreamHandler and
57
+ the live-verify PrintStream opt in by defining on_phase_change.
58
+ """
59
+ return stream is not None and hasattr(stream, "on_phase_change")
60
+
61
+
62
+ def _to_coderio_name(name: str) -> str:
63
+ """Translate a deepagents tool name to the coderio name Harness expects."""
64
+ if name == _DEEP_VERIFY_TOOL:
65
+ return "bash"
66
+ if name == _DEEP_TODO_TOOL:
67
+ return "todo"
68
+ return name
69
+
70
+
71
+ def _result_to_text(result: Any) -> str:
72
+ """Normalize a deepagents tool result (ToolMessage/str/object) to text for
73
+ the harness success/failure heuristic.
74
+
75
+ For ExecuteResponse (deepagents shell results), appends the exit_code as
76
+ ``[exit_code: N]`` so the harness's VerifyGate can parse it. Without this,
77
+ a failed test run (exit != 0) would be treated as "verified" because the
78
+ raw output text doesn't contain the exit code marker.
79
+ """
80
+ if isinstance(result, str):
81
+ return result
82
+ # deepagents ReadResult has an `error` attr for failed reads, and `file_data`
83
+ # for successful ones. Extract the error so the harness's not-found detection
84
+ # (harness.py: "not found" in result) works.
85
+ error = getattr(result, "error", None)
86
+ if isinstance(error, str) and error:
87
+ return error
88
+ # Build the text from content/output fields.
89
+ text = ""
90
+ content = getattr(result, "content", None)
91
+ if isinstance(content, str):
92
+ text = content
93
+ elif isinstance(content, list):
94
+ text = "".join(b.get("text", "") for b in content if isinstance(b, dict))
95
+ else:
96
+ output = getattr(result, "output", None)
97
+ if isinstance(output, str):
98
+ text = output
99
+ elif result is not None:
100
+ text = str(result)
101
+ # Append exit_code marker if the result has one (ExecuteResponse). This
102
+ # lets _parse_exit_code in harness.py extract it for VerifyGate.
103
+ exit_code = getattr(result, "exit_code", None)
104
+ if exit_code is not None and "[exit_code:" not in text:
105
+ text = f"{text}\n[exit_code: {exit_code}]"
106
+ return text
107
+
108
+
109
+ class HarnessMiddleware(AgentMiddleware):
110
+ """Enforces coderio's verification harness inside a deepagents agent loop.
111
+
112
+ Holds a Harness instance (with its own TodoStore). Observes every tool call
113
+ to track writes-since-verify, and intercepts the model's termination to block
114
+ "done" when code was written but never run (escalating: force-continue twice,
115
+ then release with a warning — never silent, never infinite).
116
+
117
+ Harness signals (force-continue / escalation-warn) are emitted via
118
+ runtime.stream_writer so the TUI's StreamHandler can show them. This is the
119
+ 'custom' stream channel — distinct from 'messages' (token stream) and
120
+ 'updates' (complete messages).
121
+ """
122
+
123
+ def __init__(self, stream=None, enabled: bool = True) -> None:
124
+ # Wire the phase-observation tracker when a stream consumer is present.
125
+ # The display pipeline (TUI StatusBar / stream.on_phase_change) already
126
+ # exists; without a tracker, Harness._track_phase is a no-op and the
127
+ # status bar's phase slot stays empty. We instantiate the tracker
128
+ # whenever the stream declares on_phase_change support — that's the
129
+ # signal a real UI is listening (NullStream and test stubs don't).
130
+ from coderio.agent.state import AgentStateTracker
131
+
132
+ tracker = AgentStateTracker() if _stream_supports_phase(stream) else None
133
+ self.harness = Harness(
134
+ state=HarnessState(),
135
+ todos=TodoStore(),
136
+ enabled=enabled,
137
+ state_tracker=tracker,
138
+ stream=stream,
139
+ )
140
+ self.stream = stream
141
+ self._runtime = None # captured in wrap_tool_call / after_model
142
+
143
+ def _emit(self, runtime: Any, payload: dict) -> None:
144
+ """Send a custom stream event (harness_continue / harness_warn).
145
+
146
+ Uses runtime.stream_writer so the stream consumer (deep_loop.py) picks
147
+ it up via stream_mode='custom'. Falls back to the legacy stream callback
148
+ if no runtime (e.g. in unit tests without a real graph).
149
+ """
150
+ try:
151
+ if runtime is not None and hasattr(runtime, "stream_writer"):
152
+ runtime.stream_writer(payload)
153
+ return
154
+ except Exception: # noqa: S110 — stream_writer may not be available in tests; fall back
155
+ pass
156
+ # Fallback: direct stream callback (for tests / non-graph contexts).
157
+ if self.stream is not None:
158
+ t = payload.get("type")
159
+ if t == "harness_continue" and hasattr(self.stream, "on_harness_continue"):
160
+ self.stream.on_harness_continue(payload.get("reason", ""))
161
+ elif t == "harness_warn" and hasattr(self.stream, "on_harness_warn"):
162
+ self.stream.on_harness_warn(payload.get("message", ""))
163
+
164
+ # ------------------------------------------------------- observe tool calls
165
+ def wrap_tool_call(self, request, handler):
166
+ """Observe every tool execution (ground truth) + apply PlanGate nudge.
167
+
168
+ Also captures the runtime reference for after_model's stream_writer use.
169
+ """
170
+ self._runtime = getattr(request, "runtime", None) or self._runtime
171
+ tc = getattr(request, "tool_call", None) or {}
172
+ name = tc.get("name", "")
173
+ args = dict(tc.get("args", {}) or {})
174
+ # deepagents uses 'file_path' for write/edit tools; coderio's harness
175
+ # expects 'path'. Normalize so observe() records the right path.
176
+ if "file_path" in args and "path" not in args:
177
+ args["path"] = args["file_path"]
178
+
179
+ result = handler(request)
180
+ result_text = _result_to_text(result)
181
+
182
+ # Feed ground truth to the harness (translate deepagents → coderio names).
183
+ coderio_name = _to_coderio_name(name)
184
+ self.harness.observe(coderio_name, args, result_text)
185
+
186
+ # Sync deepagents' write_todos into the harness's TodoStore so
187
+ # CompletionGate can check for pending todos. Only sync when the tool
188
+ # actually succeeded — a failed write_todos (Error result) means the
189
+ # graph state wasn't updated, so syncing args would create a mismatch.
190
+ if name == "write_todos" and "todos" in args and not result_text.startswith("Error"):
191
+ from coderio.tools.todo import Todo
192
+
193
+ todos_data = args["todos"]
194
+ if isinstance(todos_data, list):
195
+ self.harness.todos.todos = [
196
+ Todo(content=t.get("content", ""), status=t.get("status", "pending"))
197
+ for t in todos_data
198
+ if isinstance(t, dict)
199
+ ]
200
+
201
+ # Subagent delegation: the task tool returns a subagent's findings,
202
+ # which may cite files the subagent read but the MAIN agent didn't.
203
+ # Without this, GroundingGate would flag those citations as ungrounded
204
+ # and force-continue — turning a complete analysis into a "correction".
205
+ # We extract file paths from the subagent's result and add them to the
206
+ # main agent's read set, so the gate treats them as "read via subagent".
207
+ if name == "task" and result_text:
208
+ from coderio.agent.harness import _cited_files, _norm_path
209
+
210
+ for cited in _cited_files(result_text):
211
+ # Strip :line suffix, normalize.
212
+ clean = cited.rsplit(":", 1)[0] if ":" in cited else cited
213
+ self.harness.state.content_read_files.add(_norm_path(clean))
214
+
215
+ # PlanGate: nudge if writing without a todo list (soft, appends to result).
216
+ aug = self.harness.after_tool_call(coderio_name, args, result_text)
217
+ if aug and isinstance(result, str):
218
+ result = result + aug
219
+ elif aug:
220
+ # result is a ToolMessage-like object; append to its content if possible
221
+ try:
222
+ result.content = _result_to_text(result) + aug
223
+ except (AttributeError, TypeError):
224
+ pass
225
+ return result
226
+
227
+ # ------------------------------------------------------- intercept termination
228
+ @hook_config(can_jump_to=["model"])
229
+ def after_model(self, state, runtime):
230
+ """The model produced output. If it wants to end (no tool_calls) but the
231
+ harness says verification is missing, force the loop to continue.
232
+
233
+ Returns a state update dict: {'jump_to':'model','messages':[...]} to
234
+ force-continue, or None to let the agent end normally. On escalation
235
+ release, emits a harness_warn signal via stream_writer.
236
+
237
+ The @hook_config(can_jump_to=["model"]) decorator is REQUIRED — without
238
+ it, langchain's factory doesn't build the conditional edge and jump_to
239
+ silently fails (the agent ends anyway).
240
+ """
241
+ self._runtime = runtime or self._runtime
242
+ messages = state.get("messages", []) if hasattr(state, "get") else getattr(state, "messages", [])
243
+ last = messages[-1] if messages else None
244
+ # Only intercept when the model returned final text (no tool calls).
245
+ if not isinstance(last, AIMessage) or getattr(last, "tool_calls", None):
246
+ return None
247
+
248
+ # Sync todos from graph state before checking termination. This covers
249
+ # the case where write_todos was called in a PREVIOUS turn (restored
250
+ # from checkpoint) but the HarnessMiddleware's TodoStore was reset (it's
251
+ # recreated each run_deep_agent call). Without this, CompletionGate
252
+ # would see an empty todo list even though graph state has pending todos.
253
+ state_todos = get_state_todos(state)
254
+ if state_todos and isinstance(state_todos, list):
255
+ from coderio.tools.todo import Todo
256
+
257
+ self.harness.todos.todos = [
258
+ Todo(content=t.get("content", ""), status=t.get("status", "pending"))
259
+ for t in state_todos
260
+ if isinstance(t, dict)
261
+ ]
262
+
263
+ text = ""
264
+ content = getattr(last, "content", "")
265
+ if isinstance(content, str):
266
+ text = content
267
+ elif isinstance(content, list):
268
+ text = "".join(b.get("text", "") for b in content if isinstance(b, dict))
269
+
270
+ cont, inject, warn = self.harness.check_termination(text)
271
+ if cont and inject:
272
+ # The shared harness.py writes tool-name-agnostic prose that happens
273
+ # to say "bash" (its original engine's name for the shell tool).
274
+ # deepagents' shell tool is named 'execute'. Translate the standalone
275
+ # word "bash" → "execute" so the model calls the right tool.
276
+ # Word-boundary regex (not literal phrase replace) is robust to
277
+ # harness.py prose changes: "use bash", "call bash now", "run with
278
+ # bash" all map correctly without a per-phrase .replace() chain.
279
+ inject = _BASH_TO_EXECUTE.sub("execute", inject)
280
+ # Emit a visible signal so the TUI explains why the agent keeps running.
281
+ self._emit(runtime, {"type": "harness_continue", "reason": inject})
282
+ # Force-continue: inject the harness demand as a user message.
283
+ return {"jump_to": "model", "messages": [HumanMessage(content=inject)]}
284
+ if warn:
285
+ self._emit(runtime, {"type": "harness_warn", "message": warn})
286
+ # Fire the final phase transition (COMPLETE) when the turn is truly
287
+ # ending — either a clean finish or an escalation release. The TUI's
288
+ # on_phase_change clears the status bar's phase slot on 'complete'.
289
+ if self.harness.state_tracker is not None:
290
+ self.harness.state_tracker.finish(hint="turn end")
291
+ if self.stream is not None and hasattr(self.stream, "on_phase_change"):
292
+ self.stream.on_phase_change("complete", 0, "turn end")
293
+ return None
@@ -0,0 +1,52 @@
1
+ """Permission gate as a deepagents AgentMiddleware.
2
+
3
+ Wraps coderio's PermissionGate (the 4-tier access system: plan/confirm/
4
+ auto_edit/full) so it runs BEFORE every tool execution inside a deepagents
5
+ agent loop.
6
+
7
+ No path translation or WorkspacePolicy — file path isolation is handled by
8
+ deepagents' backend virtual_mode (/foo.py → {root}/foo.py, forced
9
+ relative_to(root) check). This middleware only controls WHICH tool types
10
+ may execute based on the permission mode.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from langchain.agents.middleware.types import AgentMiddleware
16
+ from langchain_core.messages import ToolMessage
17
+
18
+
19
+ class PermissionMiddleware(AgentMiddleware):
20
+ """Enforces coderio's permission gate inside a deepagents agent loop.
21
+
22
+ Intercepts every tool call via wrap_tool_call. Calls gate.check() with
23
+ the tool name + args BEFORE execution. The gate decides based on tool
24
+ type + permission mode (FULL/CONFIRM/AUTO_EDIT/PLAN).
25
+
26
+ Tool names are passed as-is (deepagents native names like 'execute',
27
+ 'write_file', 'edit_file'). The tool classification sets in base.py
28
+ use these same native names.
29
+ """
30
+
31
+ def __init__(self, gate) -> None:
32
+ self.gate = gate
33
+
34
+ def wrap_tool_call(self, request, handler):
35
+ """Check permission before executing; block if denied."""
36
+ tc = getattr(request, "tool_call", None) or {}
37
+ name = tc.get("name", "")
38
+ args = dict(tc.get("args", {}) or {})
39
+
40
+ decision = self.gate.check(name, args)
41
+ if decision is True:
42
+ return handler(request)
43
+
44
+ # Denied (False) or custom reply (str). Build a ToolMessage so the model
45
+ # gets a clear, actionable result it can react to.
46
+ tool_call_id = tc.get("id", "")
47
+ if decision is False:
48
+ content = f"Permission denied: tool {name!r} blocked in {self.gate.mode} mode."
49
+ else:
50
+ # str — user's custom reply. Deny execution but feed the instruction.
51
+ content = f"Permission denied by user: {decision}"
52
+ return ToolMessage(content=content, tool_call_id=tool_call_id, name=name)
@@ -0,0 +1,304 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from coderio.skills.store import SkillStore
6
+
7
+
8
+ @dataclass
9
+ class ActiveSkills:
10
+ """Tracks currently-active skills for the session (spec §2.4)."""
11
+
12
+ _active: dict = field(default_factory=dict) # name -> Skill
13
+
14
+ def activate(self, skill) -> bool:
15
+ """Activate a skill (load its body into context). Idempotent: if the
16
+ skill is already active, does nothing and returns False (avoids
17
+ re-reading the file on duplicate activation). Returns True if newly activated."""
18
+ if skill is None:
19
+ return False
20
+ if skill.name in self._active:
21
+ return False # already active — skip the redundant load_body()
22
+ skill.load_body()
23
+ self._active[skill.name] = skill
24
+ return True
25
+
26
+ def deactivate(self, name: str) -> bool:
27
+ return self._active.pop(name, None) is not None
28
+
29
+ def is_active(self, name: str) -> bool:
30
+ return name in self._active
31
+
32
+ def all(self) -> list:
33
+ return list(self._active.values())
34
+
35
+ def clear(self) -> None:
36
+ self._active.clear()
37
+
38
+ def active_tools(self) -> list:
39
+ """Aggregate executable tools carried by all active skills.
40
+
41
+ Each skill may ship a tools.py with a TOOLS list (see Skill.load_tools).
42
+ When a skill is activated, its tools are added here so the agent loop
43
+ can bind them to the model. Deactivation removes them (naturally — the
44
+ skill is dropped from _active). Deduplicates by tool name (first wins).
45
+ """
46
+ tools: list = []
47
+ seen: set[str] = set()
48
+ for skill in self._active.values():
49
+ for t in skill.load_tools():
50
+ t_name = getattr(t, "name", "")
51
+ if t_name and t_name not in seen:
52
+ tools.append(t)
53
+ seen.add(t_name)
54
+ return tools
55
+
56
+
57
+ def _os_context() -> str:
58
+ """Build an OS-specific context clause for the system prompt.
59
+
60
+ The bash tool's behavior differs by platform (Git Bash on Windows, native
61
+ bash on Linux/macOS). Telling the model the wrong OS causes it to emit
62
+ wrong path styles or platform-specific idioms."""
63
+ import sys
64
+
65
+ if sys.platform == "win32":
66
+ return (
67
+ "You are coderio, a coding agent working in the user's project on Windows.\n"
68
+ "You have tools to read/write/edit files, run bash commands (Git Bash), search, manage"
69
+ )
70
+ return (
71
+ "You are coderio, a coding agent working in the user's project.\n"
72
+ "You have tools to read/write/edit files, run bash commands, search, manage"
73
+ )
74
+
75
+
76
+ _BASE_INSTRUCTIONS = """\
77
+ {os_context}
78
+ todos, and fetch the web. You serve TWO kinds of requests — recognize which one and behave
79
+ accordingly. Getting this wrong (running the heavy coding workflow on a quick question, or
80
+ answering a coding task with chat alone) is a failure mode.
81
+
82
+ ## 0. Core behavior
83
+
84
+ • Be concise and direct. Don't over-explain unless asked. Lead with the answer or action,
85
+ not with "I'll now..." or "Let me..." — just do it.
86
+ • Professional objectivity: prioritize accuracy over validating the user's beliefs.
87
+ Disagree respectfully when the user is incorrect. Avoid unnecessary superlatives,
88
+ praise, or emotional validation.
89
+ • When something fails repeatedly, STOP and analyze WHY — don't keep retrying the same
90
+ approach. If you're blocked, tell the user what's wrong and ask for guidance.
91
+ • Keep working until the task is fully complete. Don't stop partway and describe what you
92
+ would do — just do it. Only yield back when done or genuinely blocked.
93
+ • For longer tasks, give brief progress updates at reasonable intervals (one sentence
94
+ recapping what's done and what's next). Don't narrate every tool call.
95
+ • Prefer small, verifiable changes over large rewrites. Make a change, test it, then
96
+ move on. If a change turns out wrong, revert and try a different approach — don't
97
+ stack patches on a broken foundation.
98
+
99
+ ## 1. First, classify the intent
100
+
101
+ Every user message is ONE of these. Decide before acting:
102
+
103
+ • CODE — the user wants code written/changed/created/deleted, a bug fixed, a feature
104
+ built, a file refactored, a command run that mutates state. Signals:
105
+ "实现/写/改/修/加/重构/跑一下/构建/部署" (CN) or
106
+ "implement/write/fix/modify/add/refactor/build/deploy/create" (EN) —
107
+ any request that should end with files or commands changing.
108
+ • QA — the user wants knowledge or an explanation:
109
+ "X 是什么 / 为什么 / 怎么理解 / 有什么区别 / 对比一下 / 解释一下" (CN) or
110
+ "what is / why / how does / explain / difference between / compare" (EN).
111
+ No file should change. You MAY use read-only tools
112
+ (list_dir/read_file/grep/web_search/web_fetch) to ground your answer, but you must NOT
113
+ write/edit files or build a todo list.
114
+ • ANALYZE — the user wants a judgment or assessment of existing code/design/architecture:
115
+ "这样设计好不好 / 哪个方案更合适 / 帮我评审 / 这个实现有什么问题 / 怎么优化" (CN) or
116
+ "is this design good / which approach is better / review this / what's wrong with /
117
+ how to improve / assess" (EN). You MUST
118
+ read the relevant code first, then answer with EVIDENCE (cite what you read), separate
119
+ fact from speculation, and present trade-offs rather than dogmatic conclusions.
120
+
121
+ If a message mixes intents (e.g. "explain X and then implement it"), handle the QA/ANALYZE
122
+ part conversationally FIRST, then switch to CODE mode for the implementation.
123
+
124
+ ## 2. CODE mode — the coding workflow (MANDATORY when writing code)
125
+
126
+ For ANY code task, follow this workflow. Each step has a corresponding skill playbook —
127
+ the step summaries below are ALWAYS in effect (they are your skeleton); the FULL playbook
128
+ for a step is loaded on-demand via activate_skill(step_name) when you reach that step and
129
+ need its detailed guidance. Do not load a playbook unless you are at that step.
130
+
131
+ 0. EXPLORE FIRST — before proposing anything, use list_dir / read_file / grep to look at
132
+ the current directory and relevant files. Build an accurate picture of the project
133
+ (structure, conventions, what already exists) BEFORE you ask questions or write code.
134
+ NEVER generate code based on assumptions about files you haven't read. If the task
135
+ touches existing code, read that code first.
136
+ 1. clarifying-questions — AFTER exploring, if the request has ANY ambiguity (tech stack
137
+ unspecified, scope unclear, hidden assumptions, possible X-Y problem), ask clarifying
138
+ questions FIRST and WAIT for the user's answers.
139
+ 2. spec-writing — for non-trivial features, write a short design/spec.
140
+ 3. task-breakdown — break the work into verifiable, ordered tasks (use the todo tool).
141
+ Batch all edits for one task before updating the todo list — don't call
142
+ write_todos after every single edit. Update todos when a task STATUS
143
+ changes (pending → in_progress → completed), not after each file edit.
144
+ Each write_todos call costs a full model round-trip, so updating 5 times
145
+ for 5 small edits wastes 4 unnecessary calls.
146
+ 4. executing-plans — implement task-by-task, verify each step, commit frequently.
147
+ Read a file ONCE per task — don't re-read it after every edit. You already
148
+ know its contents from the first read; trust your memory within the same
149
+ task. Re-read only if you're switching to a DIFFERENT task or if edits by
150
+ another tool may have changed lines you need.
151
+ EXECUTION STAGE — while/after implementing, bring in the right skill (call
152
+ activate_skill to load one when it matches):
153
+ • testing — turn the task's "完成定义" into a repeatable test.
154
+ • debugging — when a bug/error/crash appears, locate the ROOT CAUSE first
155
+ (observe → hypothesize → experiment → narrow), THEN fix — don't guess-edit.
156
+ • code-review — before committing, review correctness/security first, then
157
+ readability (leave style to linters). Issues found flow into verify-and-fix.
158
+ 5. verify-and-fix — turn "done" into "verified done"; fix root causes not symptoms.
159
+ (The harness will BLOCK you from declaring done if you wrote code but never ran it.)
160
+ 6. commit-message — write proper commit messages when committing.
161
+
162
+ Only a truly trivial, single-action CODE task (e.g. "fix this one typo", "rename this
163
+ variable") may skip clarification. If you are about to CREATE a file or WRITE multiple
164
+ lines of code, the task is NOT trivial — clarify first. When unsure whether to clarify,
165
+ ALWAYS clarify. It is better to ask one question than to build the wrong thing.
166
+
167
+ ### Large files (avoid truncation)
168
+ Your single response has a token limit (~16k). A large file (e.g. a 500-line game in one
169
+ HTML file) will be TRUNCATED if written in one write_file call, producing a broken file.
170
+ For large files: write the skeleton first (write_file with the structure/stubs), then fill
171
+ in sections with edit_file/multi_edit or note append. Prefer multiple smaller files over one
172
+ giant file. If a file needs >300 lines, split the work across several tool calls.
173
+
174
+ ## 3. QA / ANALYZE mode — the general-agent guarantees
175
+
176
+ When the intent is NOT code-writing, you are a capable general assistant. These guarantees
177
+ apply (they are what make you a usable agent, not just a code-spitter):
178
+
179
+ • **Match effort to the question.** A greeting ("你好"/"hello"), small talk, or a simple
180
+ factual question gets a direct text reply — NO tools, NO exploration, NO "let me first
181
+ look at the project." Only reach for tools when the user's question actually requires
182
+ reading files or running commands. If you can answer from your own knowledge, do so.
183
+ • Be direct and concise. Answer the actual question in the user's language (Chinese in,
184
+ Chinese out). Do not pad, do not preface, do not apologize. Lead with the answer.
185
+ • Ground answers in the project. If the question is about THIS codebase, READ the relevant
186
+ files first (read_file/grep) — never explain "your" code from assumption. Quote the
187
+ specific lines/functions you base your answer on.
188
+ • Separate fact from speculation. State what you verified by reading vs. what you infer.
189
+ For ANALYZE especially: present trade-offs and alternatives, not a single dogmatic take.
190
+ "It depends on X; if Y then A is better, if Z then B" beats "always do A".
191
+ • **Never cite a file you haven't read this turn.** A claim like "loader.py:81 does X"
192
+ is only valid if you opened loader.py. Documentation and memory go stale; the source
193
+ is ground truth. If you find yourself about to assert something about a file's
194
+ contents, read it FIRST — otherwise you risk confidently stating things that are
195
+ wrong. (This is enforced structurally: the harness blocks ungrounded citations.
196
+ Don't fight it — read the file and ground your analysis.)
197
+ • Use tools to be accurate, not to perform. web_search/web_fetch when the user asks about
198
+ something external; read_file/grep when about the repo. But don't tool-call for show on a
199
+ question you can answer directly.
200
+ • **Activate the matching skill for non-trivial ANALYZE tasks.** Skills are your playbooks —
201
+ loading one gives you a structured methodology instead of freestyling. Trigger rules:
202
+ - **First contact with a project in a new conversation** (no prior context about its
203
+ structure, tech stack, or conventions): activate_skill("onboarding-unknown-codebase")
204
+ as your VERY FIRST action, before any list_dir/read_file. This is mandatory — you
205
+ cannot meaningfully analyze or review a codebase you haven't systematically mapped.
206
+ "分析这个项目" / "看看这个目录" / "有哪些值得优化的点" (CN) or
207
+ "analyze this project" / "look at this directory" / "what can be improved" (EN)
208
+ in a fresh conversation ALL qualify as first-contact → start with onboarding.
209
+ - **Already familiar with the project** (this conversation has established context),
210
+ user asks for quality assessment: activate_skill("code-review").
211
+ - "这段代码有什么 bug/怎么调试" (CN) / "what's the bug / how to debug" (EN)
212
+ → activate_skill("debugging").
213
+ - "这段代码怎么改进/有什么问题" (CN) / "how to improve / what's wrong" (EN)
214
+ (project already understood) → activate_skill("code-review").
215
+ Do this BEFORE you start analyzing. Simple factual questions ("这个函数返回什么") don't
216
+ need a skill — they're answered by reading one file.
217
+ • One unified clarification principle across BOTH modes: when genuinely unsure what the
218
+ user needs, ask ONE focused question rather than guessing. In QA/ANALYZE this is light
219
+ (a quick clarifying reply); in CODE it is the structured clarifying-questions skill.
220
+
221
+ ## 4. Tools (reference)
222
+
223
+ read_file / ls / glob / grep — read the project (always allowed)
224
+ write_file / edit_file — mutate files (CODE mode; permission-gated)
225
+ execute — run shell commands, verify code (CODE mode; permission-gated)
226
+ write_todos — manage a task list (CODE mode, for non-trivial work)
227
+ web_search / web_fetch — external info (any mode)
228
+ activate_skill(name) — load an optional playbook (naming, debugging, error-handling,
229
+ onboarding-unknown-codebase, etc.) when a task matches it.
230
+
231
+ ### Path conventions (OVERRIDES the framework's default examples)
232
+
233
+ The framework's built-in prompt may show macOS-style paths like `/Users/name/...` —
234
+ IGNORE those examples. This project uses **virtual paths** rooted at `/`:
235
+ - `/AGENTS.md`, `/src/coderio/agent/deep_loop.py`, `/pyproject.toml` — always leading `/`
236
+ - NEVER use Windows paths (C:\\... or E:\\...) or macOS paths (/Users/...) with read_file/ls/etc.
237
+ - If a path doesn't exist, it's not a system error — the file just isn't there.
238
+
239
+ **IMPORTANT: execute (shell) paths differ from file-tool paths.**
240
+ - File tools (read_file, write_file, edit_file, ls, glob, grep) use virtual paths
241
+ like `/foo.py` — the leading `/` maps to the project root.
242
+ - The `execute` tool runs REAL shell commands. Virtual paths like `/foo.py` do NOT
243
+ work in shell — `python /foo.py` will fail because the OS interprets `/foo.py`
244
+ as an absolute OS path (e.g. `C:\foo.py` on Windows), not the project root.
245
+ - In `execute`, use RELATIVE paths instead: `python foo.py`, `cat foo.py`, `ls -la`.
246
+ - The shell's working directory is already the project root — relative paths work
247
+ correctly without any prefix.
248
+
249
+ ### Tool failure recovery (CRITICAL)
250
+
251
+ When a tool call fails, DO NOT immediately conclude "the environment is broken" or
252
+ "the file doesn't exist". Investigate using other tools first:
253
+
254
+ • read_file returns "file not found" → the path is likely wrong (relative paths resolve
255
+ from the project root, not the current file's directory). Use glob("**/filename") or
256
+ grep to find the actual location, then retry with the correct path.
257
+ • bash `python -m pytest` fails with "No module named X" → the `python` on PATH may be
258
+ the wrong interpreter (e.g. Python 2.7 on Windows). Check `.venv/Scripts/python.exe`
259
+ (Windows) or `venv/bin/python` (Linux/macOS) — if the project has a virtual environment,
260
+ use that interpreter directly. Run `which -a python python3` to see what's available.
261
+ • bash command not found → some tools differ between platforms (rg vs grep, py vs python3).
262
+ Use `which <tool>` to check availability before assuming it exists.
263
+ • Any tool error → retry with a corrected approach. The first failure is a signal to
264
+ investigate, not a conclusion. The harness will not penalize you for extra discovery
265
+ calls, but it WILL penalize you for claiming "done" based on a failed verification.
266
+ """
267
+
268
+ # The core workflow chain — these are injected as runtime rules by default, NOT
269
+ # left to the LLM to optionally activate. Cross-cutting skills remain opt-in.
270
+ CORE_CHAIN_SKILLS = (
271
+ "clarifying-questions",
272
+ "spec-writing",
273
+ "task-breakdown",
274
+ "executing-plans",
275
+ "verify-and-fix",
276
+ "commit-message",
277
+ )
278
+
279
+
280
+ def build_system_prompt(store: SkillStore, active: ActiveSkills) -> str:
281
+ parts = [_BASE_INSTRUCTIONS.format(os_context=_os_context())]
282
+
283
+ # All skills (core chain + cross-cutting) are listed by DESCRIPTION only — the
284
+ # full playbook body is loaded on-demand via activate_skill() when the model
285
+ # judges it relevant. This is the "progressive disclosure" pattern (matches
286
+ # Anthropic Agent Skills / Claude Code): keep the system prompt small (~tens of
287
+ # descriptions, not thousands of chars of body text) and load bodies lazily.
288
+ # Previously CORE_CHAIN_SKILLS bodies were all injected at once (~20K chars /
289
+ # 75% of the prompt) regardless of relevance — slow, costly, attention-diluting.
290
+ descs = store.descriptions_for_prompt()
291
+ if descs:
292
+ parts.append(
293
+ "Available skills — ACTIVATE the matching one before starting non-trivial work "
294
+ "(call activate_skill(name) to load its full playbook). Skills give you a "
295
+ "structured methodology; without one you're freestyling and likely missing "
296
+ "systemic issues. Match by the task, not by guessing:\n" + descs
297
+ )
298
+
299
+ # Explicitly user/model-activated skills: their bodies ARE injected (the model
300
+ # asked for them, so they're now in context).
301
+ active_bodies = [s.body for s in active.all()]
302
+ if active_bodies:
303
+ parts.append("Active skill playbooks (loaded into context):\n\n" + "\n\n---\n\n".join(active_bodies))
304
+ return "\n\n".join(parts)