Named agents, gated execution, and plan-lifecycle skills
ober
36daf735d2d23bd0f09ea08f4f59b2053719ec6a
--- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ tools dependably. plus [ATLAS](docs/FORGE.md#the-atlas-reliability-layer)-style verify-and-repair wraps every call: prose-to-tool rescue, step enforcement, verify-gate, and best-of-k. Always on, every provider. +- **Delegation built in.** Named sub-agent roles for the `task` tool — + read-only `delegate`, docs-scoped `doc-explorer`, gated `implementer` + (BLUEPRINT → approve → EXECUTE) — with enforced write scopes, per-agent + model routing via `jcode.json`, and plan-lifecycle skills + (`create-plan` / `resume-plan` / `generate-handover`). See + [docs/agents.md](docs/agents.md). - **A real TUI.** termbox-based panels, markdown + syntax highlighting, live diffs, themes, a sidebar with token / cost / GPU stats. @@ -63,6 +69,7 @@ make binary # produce the standalone ./jcode | **[Architecture](docs/architecture.md)** | The agent loop, module map, and how a turn flows through the guardrails. | | **[Providers & models](docs/providers.md)** | The 13 providers, model registry, per-model sampling, and hardware tiers. | | **[Tools](docs/tools.md)** | The agent's toolbox (file, bash, web, git, patch, task, MCP) and the safety model. | +| **[Named agents & plans](docs/agents.md)** | Sub-agent roles with enforced write scopes, per-agent models, gated execution, and the plan-lifecycle skills. | | **[TUI](docs/tui.md)** | Layout, keybindings, themes, and the rendering features. | | **[Forge & ATLAS reliability](docs/FORGE.md)** | Guardrails, workflows, verify-gate, best-of-k, the proxy, and the eval harness. | | **[Remote & Android](docs/remote.md)** | `serve` / `relay` / `connect` and the thin-client architecture. | --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,10 @@ elevator pitch. model registry, per-model sampling profiles, and local-model hardware tiers. - **[Tools](tools.md)** — the toolbox the agent drives (file, bash, web, git, patch, task, MCP) and the permission / sandbox / plan-mode safety model. +- **[Named agents & plan workflow](agents.md)** — sub-agent roles (`delegate`, + `doc-explorer`, `implementer`), enforced write scopes, per-agent model + routing, the gated BLUEPRINT → EXECUTE protocol, and the plan-lifecycle + skills (`/create-plan` … `/resume-plan`). - **[TUI](tui.md)** — the terminal UI: layout, the full keybinding table, themes, and the markdown / syntax / diff renderers. new file mode 100644 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,155 @@ +# Named agents, gated execution & plan workflow + +jcode's `task` tool can spawn sub-agents. Named agents give those sub-agents +**roles**: a specialized prompt, an *enforced* limit on what they may write, +and optionally their own model. On top of that sit a gated execution protocol +(plan first, get approval, then edit) and a set of plan-lifecycle skills that +persist work across sessions as files. + +## The 30-second version + +Think of jcode as a contractor who can call in three kinds of help: + +- **`delegate`** — a scout. Goes and reads code, searches, researches; comes + back with a short report. *Physically cannot* write files. +- **`doc-explorer`** — a librarian. Writes documentation and plans, but only + under `docs/` and `plans/`. Can't touch your code. +- **`implementer`** — a builder. The only one who edits code, and only in two + steps: first it returns a *blueprint* (numbered steps + one verify command), + then — after approval — a second call executes that blueprint. + +Each helper runs in its own context window, so the main conversation stays +small while the helpers do the noisy reading and editing. + +## Using it: you mostly don't have to do anything + +The agents are driven *by the model* through the `task` tool — the tool +description tells it what each role is for. Just ask for work the normal way: + +``` +› how does the compaction strategy decide what to drop? ← model may scout with delegate +› document the provider layer ← model may use doc-explorer +› add a --json flag to the status command ← model may run the implementer gate +``` + +You can also steer explicitly — these phrasings work well: + +``` +› use a delegate to find every caller of refresh-system-prompt +› have the implementer blueprint this change first, then show me the plan +› spawn doc-explorer to write docs/modules/provider.md +``` + +`/agents` lists the roles (and any variants you configured) at any time. + +## The gated implementer protocol + +For non-trivial changes, the safest flow is two `task` calls sharing a +`task_id` (the sub-agent keeps its memory between them): + +``` +1. BLUEPRINT task(agent: "implementer", task_id: "wp-1", + prompt: "MODE: BLUEPRINT\n<the change + context>") + → numbered step list + ONE verify command. No edits happen. + +2. (review) the calling agent — or you — reads the blueprint. + +3. EXECUTE task(task_id: "wp-1", + prompt: "MODE: EXECUTE — approved\n<the blueprint>") + → implements, runs the verify command, returns a digest: + outcome / files edited / verify result / follow-ups. +``` + +Because the EXECUTE call resumes the same sub-agent, all the reading it did +while blueprinting is still in its head — nothing is re-explored. If a +blueprint is wrong, reply with objections in another BLUEPRINT call on the +same `task_id` instead of approving. + +Task sessions live in memory for the jcode process (most recent 32 kept). + +## Plan workflow: multi-session work that survives restarts + +Five built-in skills (run them like any slash command) maintain a file-based +plan under `plans/<name>/` — the files *are* the shared memory, so a new +session picks up exactly where the old one stopped: + +| Skill | When | What it does | +|---|---|---| +| `/create-plan` | starting a feature | writes `plan.md`, `phases/phase-N.md`, `todo.md` | +| `/review-plan` | after planning | fresh-eyes review; findings rated Critical/Major/Minor/Note | +| `/execute-work-package` | implementing a phase | drives the BLUEPRINT → approve → EXECUTE gate | +| `/generate-handover` | end of session | narrative handover: decisions, state, blockers, next steps | +| `/resume-plan` | next session | reads plan + todo + latest handover, briefs you, waits for go | + +A typical week: + +``` +Mon › /create-plan (discuss; phases written to plans/auth-rework/) + › /review-plan (catches a gap in phase 2 — fix it now, cheap) + › /execute-work-package (phase 1: blueprint → you approve → execute) + › /generate-handover +Tue › /resume-plan ("phase 1 done, phase 2 blocked on X — continue?") + › ... +``` + +The plan directory layout: + +``` +plans/<name>/ +├── plan.md goal, scope, phase list +├── phases/phase-N.md per-phase scope + acceptance criteria +├── implementation/phase-N-impl.md concrete steps grounded in the code +├── reviews/ review findings +├── todo.md checkbox progress +└── handovers/session-<date>.md session narratives +``` + +## Variants: routing roles to different models + +Add an `"agents"` block to `jcode.json`. `extends` inherits a built-in's +prompt and write scope; the rest overrides: + +```json +{ + "agents": { + "delegate-fast": {"extends": "delegate", + "provider": "ollama", "model": "qwen3:8b"}, + "delegate-strong": {"extends": "delegate", + "provider": "anthropic", "model": "claude-opus-4-8"}, + "implementer-safe": {"extends": "implementer", + "provider": "anthropic", "model": "claude-opus-4-8"}, + "reviewer": {"description": "fresh-context reviewer", + "prompt": "You are a critical reviewer. ...", + "write_scope": ["plans/"]} + } +} +``` + +Then: cheap local model for scouting, strong cloud model for code changes — +"use delegate-fast to map the test suite, then implementer-safe for the fix." +A variant with neither `provider` nor `model` uses the session's current +model. `write_scope` is `"all"`, `"none"`, or a list of path prefixes. + +## How the safety actually works + +- Write scopes are enforced in the **tool layer** (`core/agent-defs.ss`, + checked by `file.ss` and `apply-patch.ss`), not just by prompt. A scoped + sub-agent that tries `write src/foo.ss` gets a refusal string back. +- Nesting only **narrows**: if a docs-scoped agent spawns another task, the + child's scope is intersected with `docs/`+`plans/` — it can never widen. +- Scopes gate the *file* tools. `bash` remains governed by the + [permissions rules](tools.md#2-bash-permissions) and OS sandbox, and the + delegate's role prompt forbids mutation — but a hostile model could still + try `bash` writes; keep `permissions.deny` tight if that matters to you. +- All existing layers still apply underneath: PLAN/BUILD mode, generated- + artifact protection, checkpoints (`/undo`), sensitive-path denylist. + +## Reference + +`task` tool parameters: `description`, `prompt` (required); `system` (skill +name or literal system text); `agent` (role name); `task_id` (resumable +session id — required for BLUEPRINT → EXECUTE). + +Related: [tools.md](tools.md) (toolbox + safety model), [cli.md](cli.md) +(slash commands), [FORGE.md](FORGE.md) (verify-gate — the *after*-edit gate +that complements the blueprint's *before*-edit gate). --- a/docs/cli.md +++ b/docs/cli.md @@ -119,6 +119,7 @@ Available in the REPL and TUI. | Command | Effect | |---|---| | `/tools` | List available tools. | +| `/agents` | List named sub-agent roles for the `task` tool (built-ins + `jcode.json` variants). | | `/mcp` | Toggle MCP tools on/off. | | `/plugins` | List loaded plugins. | | `/skills` | List built-in and user skills. | --- a/docs/tools.md +++ b/docs/tools.md @@ -44,7 +44,7 @@ page is the catalogue plus the safety model that wraps every call. | Tool | Purpose | Parameters | |---|---|---| | `batch` | Run several tool calls in parallel (green threads) | `calls[]` of `{tool,args}` | -| `task` | Spawn a sub-agent with its own context | `description`, `prompt`, `system?` | +| `task` | Spawn a sub-agent with its own context | `description`, `prompt`, `system?`, `agent?`, `task_id?` | | `repomap` | Condensed project map (top files by PageRank + key symbols) | *(none)* | `task`'s `system` argument can name a skill, so a sub-agent can be launched with @@ -52,6 +52,51 @@ a specialized prompt. LSP lookups (`lsp_definition` / `lsp_hover` / `lsp_references`) exist internally but are **not** exposed to the model — they back editor integrations, not the agent loop. +### Named agents (`task`'s `agent` parameter) + +Full guide with examples and the plan workflow: **[agents.md](agents.md)**. + +Three built-in roles, each bundling a role prompt, an **enforced file-write +scope**, and optional per-agent model routing (`/agents` lists them): + +| Agent | Role | Write scope | +|---|---|---| +| `delegate` | Read-only exploration, research, targeted reading | none (file write tools refused) | +| `doc-explorer` | Documentation + planning artifacts | `docs/`, `plans/` only | +| `implementer` | Code changes via the gated BLUEPRINT → EXECUTE protocol | unrestricted | + +Write scopes are enforced in the tool layer (`agent-defs.ss` + +`file.ss`/`apply-patch.ss`), not just by prompt — and nesting can only +*narrow*: a scoped sub-agent spawning another task can never widen access. + +Variants live in `jcode.json` under `"agents"`; `extends` inherits a +built-in's prompt/scope while pinning a different model: + +```json +"agents": { + "delegate-strong": {"extends": "delegate", "provider": "anthropic", + "model": "claude-opus-4-8"}, + "implementer-fast": {"extends": "implementer", "model": "qwen2.5-coder:14b"} +} +``` + +### Resumable tasks & the gated implementer protocol + +Passing `task_id` makes a sub-agent conversation resumable (in-memory, capped +at 32): a later `task` call with the same id continues it, the new `prompt` +arriving as the next user message. This powers the two-step implementer gate: + +1. `task(agent: "implementer", task_id: "wp-1", prompt: "MODE: BLUEPRINT\n…")` + → returns a numbered step plan + one verify command, **no edits**. +2. Caller (or user) reviews the blueprint. +3. `task(task_id: "wp-1", prompt: "MODE: EXECUTE — approved\n…")` → implements + with the planning context still in the sub-agent's head, runs the verify + command, returns a digest. + +The plan-lifecycle skills (`/skills`: `create-plan`, `resume-plan`, +`execute-work-package`, `review-plan`, `generate-handover`) drive this +protocol against persistent `plans/<name>/` artifacts that survive sessions. + ## The safety model Four layers gate what the agent can do. They apply to every call regardless of new file mode 100644 --- /dev/null +++ b/src/jcode/core/agent-defs.ss @@ -0,0 +1,285 @@ +;;; jcode named agent definitions +;;; +;;; A small taxonomy of sub-agent roles for the task tool, inspired by +;;; opencode-processing-skills. Each definition bundles: +;;; * a role prompt — prepended to the task prompt (skill-style) +;;; * a write scope — which paths the sub-agent's file tools may write +;;; * model routing — optional provider/model override for the run +;;; +;;; Built-ins: +;;; delegate — read-only exploration/research (write scope: none) +;;; doc-explorer — writes ONLY under docs/ and plans/ +;;; implementer — full write access, gated BLUEPRINT -> EXECUTE protocol +;;; +;;; Users add variants in jcode.json under "agents". A variant `extends` a +;;; built-in (inheriting prompt/scope) and typically pins a model: +;;; +;;; "agents": { +;;; "delegate-strong": {"extends": "delegate", +;;; "provider": "anthropic", "model": "claude-opus-4-8"}, +;;; "implementer-fast": {"extends": "implementer", "model": "qwen2.5-coder:14b"}, +;;; "reviewer": {"description": "fresh-context plan reviewer", +;;; "prompt": "You are a critical reviewer...", +;;; "write_scope": ["plans/"]} +;;; } +;;; +;;; write_scope in config: "all" (unrestricted), "none" (read-only file +;;; tools), or a list of path prefixes relative to the project root. +;;; +;;; Enforcement: the task tool parameterizes current-write-scope from the +;;; resolved definition; file.ss consults write-scope-allows? on every +;;; write-path tool. This is a guardrail for file tools — bash is still +;;; governed by the permissions module, and read tools are never scoped. + +(export agent-def-lookup + agent-def-names + agent-def? + make-agent-def + agent-def-name + agent-def-description + agent-def-prompt + agent-def-provider + agent-def-model + agent-def-write-scope + parse-agent-def + parse-write-scope + builtin-agent-defs + current-write-scope + write-scope-allows? + write-scope-intersect + write-scope-error + write-scope-label) + +(import :std/misc/string + :std/os/path + ./config) + +(defstruct agent-def (name description prompt provider model write-scope)) + +;; Write scope of the *current* tool execution context. #f = unrestricted +;; (normal operation); 'none = file write tools refused; a list of path +;; prefixes = writes allowed only under those prefixes. The task tool +;; parameterizes this per sub-agent run. +(def current-write-scope (make-parameter #f)) + +;; ── Built-in role prompts ──────────────────────────────────────────── + +(def *delegate-prompt* + (string-append +"You are a focused research sub-agent (role: delegate). Your job is\n" +"exploration, targeted reading, and investigation — NOT modification.\n" +"\n" +"Rules:\n" +"- You are READ-ONLY: file write tools are disabled for you. Do not\n" +" attempt write/edit/patch; do not mutate state via bash either.\n" +"- Find things with grep/glob, then read only what you need.\n" +"- Use batch to parallelize independent reads.\n" +"\n" +"Output contract — your final answer is consumed by another agent:\n" +"- Lead with the conclusion, then supporting evidence.\n" +"- Cite concrete locations as path:line for every claim.\n" +"- No exploration narration, no raw file dumps. Concise findings only.\n")) + +(def *doc-explorer-prompt* + (string-append +"You are a documentation and planning sub-agent (role: doc-explorer).\n" +"You read code anywhere, but you write ONLY under docs/ and plans/ —\n" +"writes elsewhere are refused by the tool layer. Never modify code.\n" +"\n" +"Artifact conventions:\n" +"- docs/overview.md — project map: purpose, module list (one line each),\n" +" key entry points. References detail docs instead of duplicating them.\n" +"- docs/modules/<name>.md — per-module: responsibility, key files and\n" +" symbols (each with a one-line purpose), dependencies, gotchas.\n" +"- plans/<name>/plan.md — goal, scope, phase list (what/why per phase).\n" +"- plans/<name>/phases/phase-N.md — one phase: scope, acceptance criteria.\n" +"- plans/<name>/implementation/phase-N-impl.md — concrete steps grounded\n" +" in the current code: files to touch, symbols, one verify command, and\n" +" a Reality Check section listing mismatches found while grounding.\n" +"- plans/<name>/todo.md — checkbox list, kept in sync with progress.\n" +"- plans/<name>/handovers/session-<date>.md — session narrative:\n" +" decisions + rationale, current state, blockers, concrete next steps.\n" +"\n" +"Working style:\n" +"- Write early, flush often: materialize findings into the artifact as\n" +" you go rather than holding the whole codebase in context.\n" +"- Inventories must explain: every listed file/symbol gets a purpose.\n" +"- Update incrementally; preserve manual enrichments in existing docs.\n" +"- End by listing the artifact paths you wrote.\n")) + +(def *implementer-prompt* + (string-append +"You are an execution sub-agent (role: implementer). You make code\n" +"changes following a gated two-step protocol. The FIRST LINE of your\n" +"task prompt is either MODE: BLUEPRINT or MODE: EXECUTE.\n" +"\n" +"MODE: BLUEPRINT — plan only, NO edits:\n" +"- Read the relevant code, then return a numbered step list: file to\n" +" touch per step, what changes, and ONE verify command that exercises\n" +" the changed behavior (not just a lint/typecheck).\n" +"- Do not write/edit any file in this mode.\n" +"\n" +"MODE: EXECUTE — the prompt contains an approved blueprint:\n" +"- Implement exactly the blueprint steps. If reality contradicts the\n" +" blueprint, stop and report the mismatch instead of improvising.\n" +"- Run the verify command. If it fails, make minimal targeted fixes\n" +" only; if a larger change is needed, stop and report.\n" +"- Return a compact digest: outcome (passed/failed/blocked), files\n" +" edited, verify command + result, 1-3 bullets of follow-ups.\n" +"\n" +"Coding standards (both modes):\n" +"- Minimal changes; preserve existing patterns and naming.\n" +"- Fix root causes, not symptoms. No silent failure paths.\n" +"- No hardcoded values where the codebase has a config mechanism.\n" +"- Never commit — git operations belong to the calling agent.\n")) + +(def (builtin-agent-defs) + (list + (make-agent-def "delegate" + "read-only exploration, research, targeted reading" + *delegate-prompt* #f #f 'none) + (make-agent-def "doc-explorer" + "documentation + planning artifacts; writes only docs/ and plans/" + *doc-explorer-prompt* #f #f '("docs/" "plans/")) + (make-agent-def "implementer" + "gated code execution: BLUEPRINT then EXECUTE with approval" + *implementer-prompt* #f #f #f))) + +;; ── Config merge ───────────────────────────────────────────────────── + +(def (parse-write-scope v) + "Normalize a config write_scope value: \"all\" -> #f (unrestricted), + \"none\" -> 'none, list of strings -> that list. Anything else -> #f." + (cond + ((equal? v "none") 'none) + ((equal? v "all") #f) + ((and (list? v) (pair? v) (string? (car v))) v) + (else #f))) + +(def (parse-agent-def name spec base) + "Build an agent-def NAME from config hash SPEC, inheriting unset fields + from BASE (an agent-def or #f). Exported for tests." + (let ((sget (lambda (k) (and (hash-table? spec) + (let ((v (hash-get spec k))) + (and (string? v) (not (string=? v "")) v)))))) + (make-agent-def + name + (or (sget "description") + (and base (agent-def-description base)) + "user-defined agent") + (or (sget "prompt") + (and base (agent-def-prompt base)) + #f) + (or (sget "provider") + (and base (agent-def-provider base))) + (or (sget "model") + (and base (agent-def-model base))) + (if (and (hash-table? spec) (hash-get spec "write_scope")) + (parse-write-scope (hash-get spec "write_scope")) + (if base (agent-def-write-scope base) #f))))) + +(def (config-agent-defs) + "Agent definitions from the jcode.json \"agents\" block, resolving + `extends` against built-ins (and, second pass, other config entries)." + (let ((block (config-ref "agents"))) + (if (not (hash-table? block)) + '() + (let ((names (hash-keys block))) + (map + (lambda (name) + (let* ((spec (hash-get block name)) + (ext (and (hash-table? spec) (hash-get spec "extends"))) + (base (and (string? ext) (builtin-lookup ext)))) + (parse-agent-def name spec base))) + (list-sort string<? names)))))) + +(def (builtin-lookup name) + (let loop ((defs (builtin-agent-defs))) + (cond + ((null? defs) #f) + ((string=? (agent-def-name (car defs)) name) (car defs)) + (else (loop (cdr defs)))))) + +(def (agent-def-lookup name) + "Resolve NAME to an agent-def: config entries override built-ins." + (and (string? name) + (or (let loop ((defs (config-agent-defs))) + (cond + ((null? defs) #f) + ((string=? (agent-def-name (car defs)) name) (car defs)) + (else (loop (cdr defs))))) + (builtin-lookup name)))) + +(def (agent-def-names) + "Sorted names of all available agent definitions (built-in + config)." + (let ((seen (make-hash-table))) + (for-each (lambda (d) (hash-put! seen (agent-def-name d) #t)) + (builtin-agent-defs)) + (for-each (lambda (d) (hash-put! seen (agent-def-name d) #t)) + (config-agent-defs)) + (list-sort string<? (hash-keys seen)))) + +;; ── Write-scope enforcement ────────────────────────────────────────── + +(def (scope-relative-path p) + "Normalize P for prefix matching: strip ./ and, for absolute paths + under the current directory, strip the cwd prefix. Absolute paths + outside the cwd are returned as-is (and so never match a relative + scope prefix — correctly refused)." + (let* ((p (if (string-prefix? "./" p) + (substring p 2 (string-length p)) + p)) + (cwd (let ((d (current-directory))) + (if (string-suffix? "/" d) d (string-append d "/"))))) + (if (string-prefix? cwd p) + (substring p (string-length cwd) (string-length p)) + p))) + +(def (write-scope-allows? scope path) + "Does SCOPE permit writing PATH? #f scope = everything; 'none = nothing; + list = path must fall under one of the prefixes." + (cond + ((not scope) #t) + ((eq? scope 'none) #f) + ((not (string? path)) #f) + (else + (let ((rel (scope-relative-path path))) + (let loop ((prefixes scope)) + (cond + ((null? prefixes) #f) + ((string-prefix? (car prefixes) rel) #t) + (else (loop (cdr prefixes))))))))) + +(def (write-scope-intersect parent child) + "Combine a PARENT scope with a CHILD scope so nesting can only narrow: + a scoped sub-agent spawning another task can never widen file access. + #f = unrestricted, 'none = no writes, list = allowed prefixes." + (cond + ((not parent) child) + ((not child) parent) + ((or (eq? parent 'none) (eq? child 'none)) 'none) + (else + ;; Keep child prefixes that fall inside some parent prefix. + (let ((kept (filter + (lambda (c) + (let loop ((ps parent)) + (cond + ((null? ps) #f) + ((string-prefix? (car ps) c) #t) + (else (loop (cdr ps)))))) + child))) + (if (null? kept) 'none kept))))) + +(def (write-scope-label scope) + (cond + ((not scope) "all") + ((eq? scope 'none) "none (read-only)") + (else (string-join scope ", ")))) + +(def (write-scope-error tool path) + "Error string if the current write scope refuses PATH, else #f." + (let ((scope (current-write-scope))) + (if (write-scope-allows? scope path) + #f + (format "Error: ~a refused — this sub-agent's write scope is [~a] and ~a is outside it. Report the needed change to the calling agent instead." + tool (write-scope-label scope) path)))) --- a/src/jcode/core/agent.ss +++ b/src/jcode/core/agent.ss @@ -2,7 +2,9 @@ (export agent-run agent-chat + agent-chat-messages agent-step + system-prompt current-stream-cb current-tool-cb current-usage-cb @@ -1521,6 +1523,32 @@ Be concise. Prefer edit over write for modifying existing files. (new-msgs (append msgs (list response) results))) (agent-chat-loop-stream provider new-msgs tools (+ round 1))))))))) +(def (agent-chat-messages messages) + "Run the non-streaming chat loop over an explicit MESSAGES list (system + + history already built by the caller). Returns (values final-content + final-messages) so callers — the resumable task tool — can persist the + transcript and continue the same sub-agent conversation later." + (let ((provider (get-current-provider)) + (tools (get-tool-schemas))) + (agent-chat-loop-track provider messages tools 0))) + +(def (agent-chat-loop-track provider messages tools round) + (let ((response (chat-with-expert provider messages tools))) + (cond + ((not (message-tool-calls response)) + (values (or (message-content response) "") + (append messages (list response)))) + ((>= round *max-tool-rounds*) + (let* ((results (execute-tool-calls (message-tool-calls response))) + (new-messages (append messages (list response) results)) + (final (chat-with-expert provider new-messages '()))) + (values (or (message-content final) "") + (append new-messages (list final))))) + (else + (let* ((results (execute-tool-calls (message-tool-calls response))) + (new-messages (append messages (list response) results))) + (agent-chat-loop-track provider new-messages tools (+ round 1))))))) + (def (agent-step messages) (let* ((provider (get-current-provider)) (tools (get-tool-schemas))) --- a/src/jcode/core/builtin-skills.ss +++ b/src/jcode/core/builtin-skills.ss @@ -100,9 +100,133 @@ "\n" "Report what was saved, suggested, voted for, and security patterns added when done.\n")) +;; ── Plan-lifecycle skills ──────────────────────────────────────────── +;; Ported from opencode-processing-skills: a file-based plan workflow +;; (plans/<name>/...) that survives sessions. The plan artifacts are the +;; interface — every skill reads/writes the same structure. + +(def *create-plan-prompt* + (string-append +"Create a persistent plan for the feature/task being discussed. Settle\n" +"open requirement questions with the user BEFORE writing.\n" +"\n" +"Write these artifacts (use a short kebab-case <name> for the plan):\n" +"\n" +"1. plans/<name>/plan.md — goal, context, scope (in/out), and a phase\n" +" list. Each phase: what it delivers and why it's a separate phase.\n" +" Size phases to roughly one working session; order them so earlier\n" +" phases are foundations for later ones.\n" +"2. plans/<name>/phases/phase-N.md — per phase: scope, deliverables,\n" +" acceptance criteria. WHAT and WHY only — the HOW lives in the\n" +" implementation plan, authored later, so the approach can change\n" +" without rewriting scope.\n" +"3. plans/<name>/todo.md — checkbox list seeded with Phase 1 items.\n" +"\n" +"Rules:\n" +"- Ground the plan in the real codebase: verify module/file claims with\n" +" grep/read (or delegate exploration via task agent=\"delegate\") before\n" +" writing them down.\n" +"- Do NOT write implementation steps yet; that is a separate pass that\n" +" grounds each phase against the code (skill: implementation planning).\n" +"- Prefer task agent=\"doc-explorer\" for writing the artifacts when the\n" +" plan is large; otherwise write them directly.\n" +"- Finish by showing the user the plan summary and phase list.\n")) + +(def *resume-plan-prompt* + (string-append +"Resume work on an existing plan. Read, in order:\n" +"\n" +"1. plans/*/plan.md — if several plans exist, ask which one.\n" +"2. plans/<name>/todo.md — current progress.\n" +"3. plans/<name>/handovers/ — the LATEST handover, if any.\n" +"4. The active phase file and its implementation plan\n" +" (plans/<name>/implementation/phase-N-impl.md) if present.\n" +"\n" +"Then brief the user: where the plan stands, what was last done, any\n" +"blockers recorded, and the next 1-3 concrete tasks. Do not start\n" +"implementing until the user confirms the direction. If artifacts are\n" +"missing (no impl plan for the active phase, stale todo), say so and\n" +"offer to create them first.\n")) + +(def *generate-handover-prompt* + (string-append +"Write a session handover so the next session (or another agent) can\n" +"continue seamlessly. Target file:\n" +"plans/<name>/handovers/session-<YYYY-MM-DD>.md (docs/handovers/ if no\n" +"plan is active). Use today's date; add -2, -3 if the file exists.\n" +"\n" +"Sections:\n" +"- Session Summary — what this session worked on, 2-4 sentences.\n" +"- Progress — completed / in-progress / not-started, from todo.md and\n" +" the actual session work.\n" +"- Key Decisions — table: decision | alternatives considered | rationale.\n" +"- Current State — files modified (git_status/git_diff), tests passing\n" +" or failing, anything half-done.\n" +"- Blockers & Issues — anything unresolved, with what was already tried.\n" +"- Next Steps — concrete and actionable: name the file, symbol, and\n" +" starting point (\"implement X in src/foo.ss starting from stub Y\"),\n" +" not vague (\"continue with X\").\n" +"\n" +"Capture the session NARRATIVE (decisions, rationale, surprises), not a\n" +"copy of the todo list. Also update plans/<name>/todo.md checkboxes to\n" +"match reality before writing the handover.\n")) + +(def *review-plan-prompt* + (string-append +"Review a plan or implementation artifact with fresh eyes. You did NOT\n" +"author it — that distance is the point: catch gaps familiarity hides.\n" +"\n" +"Steps:\n" +"1. Read the target artifact (plan.md / phase files / impl plan — or the\n" +" implementation diff via git_diff if reviewing executed work).\n" +"2. Verify claims against the actual codebase: file paths exist, named\n" +" symbols exist, steps are executable as written, acceptance criteria\n" +" are testable. Delegate spot-checks via task agent=\"delegate\" when\n" +" that keeps your context lean.\n" +"3. Write findings to plans/<name>/reviews/<artifact>-review.md, each\n" +" finding rated: Critical (plan will fail / wrong behavior),\n" +" Major (significant gap or risk), Minor (worth fixing), Note.\n" +"\n" +"For implementation reviews additionally assess test quality: do the\n" +"tests exercise changed BEHAVIOR (not just mocks/lint), would they catch\n" +"a regression?\n" +"\n" +"Findings are advisory — end with a one-paragraph verdict and let the\n" +"caller decide. Do not modify the artifacts you review.\n")) + +(def *execute-work-package-prompt* + (string-append +"Execute the active phase of a plan through the gated implementer\n" +"protocol. Precondition: an implementation plan exists\n" +"(plans/<name>/implementation/phase-N-impl.md). If missing, stop and\n" +"offer to author it first.\n" +"\n" +"1. Read the impl plan and its Required Context files.\n" +"2. BLUEPRINT call — task with agent=\"implementer\", a fresh task_id\n" +" (e.g. wp-phase-N), and a prompt starting 'MODE: BLUEPRINT' that\n" +" includes the impl-plan steps and context. The sub-agent returns a\n" +" numbered step list + one verify command. NO edits happen.\n" +"3. GATE — show the blueprint to the user. Proceed only on approval;\n" +" on objections, revise via another BLUEPRINT call (same task_id).\n" +"4. EXECUTE call — task with the SAME task_id, prompt starting\n" +" 'MODE: EXECUTE — approved: <token>'. The sub-agent implements,\n" +" runs the verify command, and returns a digest (outcome, files\n" +" edited, verify result, follow-ups).\n" +"5. Post-process: verify passed -> update plans/<name>/todo.md and\n" +" report; verify failed -> show the digest, decide with the user\n" +" whether to fix forward (new EXECUTE call) or roll back (/undo);\n" +" blocked -> surface the mismatch, update the impl plan, re-blueprint.\n" +"\n" +"Never git-commit from the sub-agent; commits belong to the user.\n")) + (def *builtin-skills* (list - (cons "save-discoveries" *save-discoveries-prompt*))) + (cons "create-plan" *create-plan-prompt*) + (cons "execute-work-package" *execute-work-package-prompt*) + (cons "generate-handover" *generate-handover-prompt*) + (cons "resume-plan" *resume-plan-prompt*) + (cons "review-plan" *review-plan-prompt*) + (cons "save-discoveries" *save-discoveries-prompt*))) (def (builtin-skill name) "Return the prompt text for the builtin skill NAME, or #f if not builtin." --- a/src/jcode/tool/apply-patch.ss +++ b/src/jcode/tool/apply-patch.ss @@ -27,6 +27,7 @@ :std/misc/ports :std/os/path :jcode/core/log + :jcode/core/agent-defs :jcode/tool/registry) (def logger (make-logger "tool.apply-patch")) @@ -173,11 +174,16 @@ ;;; ----- Op application ------------------------------------------------ (def (apply-op op) - (case (op-kind op) - ((add) (apply-add (op-path op) (op-body op))) - ((delete) (apply-delete (op-path op))) - ((update) (apply-update (op-path op) (op-body op))) - (else "unknown op kind"))) + ;; Write-scope gate (sub-agent sandboxing) covers every op kind — + ;; delete is a mutation too. + (let ((scope-err (write-scope-error "apply_patch" (op-path op)))) + (if scope-err + scope-err + (case (op-kind op) + ((add) (apply-add (op-path op) (op-body op))) + ((delete) (apply-delete (op-path op))) + ((update) (apply-update (op-path op) (op-body op))) + (else "unknown op kind"))))) (def (apply-add path body) (cond --- a/src/jcode/tool/file.ss +++ b/src/jcode/tool/file.ss @@ -8,6 +8,7 @@ :std/misc/ports :std/misc/string :jcode/core/log + :jcode/core/agent-defs :jcode/tool/registry) (def logger (make-logger "tool.file")) @@ -190,6 +191,7 @@ (cond ((sensitive-path? path) (sensitive-path-error "write" path)) ((generated-artifact-error "write" path) => (lambda (e) e)) + ((write-scope-error "write" path) => (lambda (e) e)) (else (let ((dir (path-directory path))) (when (and dir (not (equal? dir "")) (not (file-exists? dir))) @@ -209,6 +211,7 @@ (cond ((sensitive-path? path) (sensitive-path-error "edit" path)) ((generated-artifact-error "edit" path) => (lambda (e) e)) + ((write-scope-error "edit" path) => (lambda (e) e)) ((not (file-exists? path)) (format "Error: File not found: ~a" path)) (else @@ -249,6 +252,7 @@ (cond ((sensitive-path? path) (sensitive-path-error "edit_block" path)) ((generated-artifact-error "edit_block" path) => (lambda (e) e)) + ((write-scope-error "edit_block" path) => (lambda (e) e)) ((not (file-exists? path)) (format "Error: File not found: ~a" path)) (else @@ -399,6 +403,7 @@ (cond ((sensitive-path? path) (sensitive-path-error "multi-edit" path)) ((generated-artifact-error "multi-edit" path) => (lambda (e) e)) + ((write-scope-error "multi-edit" path) => (lambda (e) e)) ((not (file-exists? path)) (format "Error: File not found: ~a" path)) (#t @@ -463,6 +468,7 @@ (result (apply-unified-patch content patch-str))) (cond ((generated-artifact-error "patch" path) => (lambda (e) e)) + ((write-scope-error "patch" path) => (lambda (e) e)) ((string? result) (write-file-string path result) (format "Successfully applied patch to ~a" path)) --- a/src/jcode/tool/task.ss +++ b/src/jcode/tool/task.ss @@ -7,67 +7,193 @@ ;;; tool result. This is the lever you use to delegate noisy ;;; research/exploration without polluting the main context. ;;; +;;; Named agents (core/agent-defs.ss): pass agent="delegate" / +;;; "doc-explorer" / "implementer" (or a jcode.json variant) to give the +;;; sub-agent a role prompt, an enforced file-write scope, and optional +;;; per-agent provider/model routing. +;;; +;;; Resumable tasks: pass a task_id to persist the sub-agent's transcript +;;; in memory; a later call with the same task_id continues that +;;; conversation. This enables the gated implementer protocol — call 1 +;;; (MODE: BLUEPRINT) plans, call 2 (MODE: EXECUTE) implements with the +;;; full planning context still in the sub-agent's head. +;;; ;;; Args: ;;; description : short label for logging ;;; prompt : the actual task to give the sub-agent ;;; system : optional system-prompt override (skill-style) +;;; agent : optional named agent definition +;;; task_id : optional stable id for a resumable sub-agent session ;;; ;;; Returns the sub-agent's final assistant content. -(export init-task-tool) +(export init-task-tool + task-session-store + task-session-put! + task-session-clear!) (import :std/misc/string :jerboa/core :jcode/core/log :jcode/core/agent + :jcode/core/agent-defs + :jcode/core/config + :jcode/core/message :jcode/core/skill + :jcode/core/builtin-skills :jcode/tool/registry) (def logger (make-logger "tool.task")) +;; ── Resumable task sessions ────────────────────────────────────────── +;; task_id -> (vector seq agent-name messages). In-memory only — task +;; sessions live for the jcode process, which matches their purpose +;; (blueprint -> execute within one parent turn or session). + +(def *task-sessions* (make-hash-table)) +(def *task-session-seq* 0) +(def *task-session-cap* 32) + +(def (task-session-store) *task-sessions*) + +(def (task-session-clear!) + (set! *task-sessions* (make-hash-table)) + (set! *task-session-seq* 0)) + +(def (task-session-put! id agent-name messages) + (set! *task-session-seq* (+ *task-session-seq* 1)) + (hash-put! *task-sessions* id (vector *task-session-seq* agent-name messages)) + (evict-oldest-if-over-cap!)) + +(def (evict-oldest-if-over-cap!) + (let ((ids (hash-keys *task-sessions*))) + (when (> (length ids) *task-session-cap*) + (let loop ((ids ids) (oldest #f) (oldest-seq #f)) + (cond + ((null? ids) + (when oldest (hash-remove! *task-sessions* oldest))) + (else + (let ((seq (vector-ref (hash-get *task-sessions* (car ids)) 0))) + (if (or (not oldest-seq) (< seq oldest-seq)) + (loop (cdr ids) (car ids) seq) + (loop (cdr ids) oldest oldest-seq))))))))) + +;; ── Tool registration ──────────────────────────────────────────────── + (def (init-task-tool) (register-tool! "task" - "Spawn a subordinate agent to perform a focused task with its own context. Use for research, codebase exploration, or any work that would otherwise bloat the main conversation. Returns the sub-agent's final answer as a string. The sub-agent has access to the same tools as the parent." - (make-schema '(("description" "string" "Short label describing the sub-task (5-15 words)" #t) - ("prompt" "string" "The full prompt for the sub-agent. Self-contained — sub-agent does not see parent context." #t) - ("system" "string" "Optional system-prompt override or name of a skill (e.g. 'review')" #f))) + (task-tool-description) + (make-schema + `(("description" "string" "Short label describing the sub-task (5-15 words)" #t) + ("prompt" "string" "The full prompt for the sub-agent. Self-contained — sub-agent does not see parent context." #t) + ("system" "string" "Optional system-prompt override or name of a skill (e.g. 'review')" #f) + ("agent" "string" ,(format "Optional named agent role: ~a. Each has a role prompt and an enforced file-write scope; variants in jcode.json may pin a different model." (string-join (agent-def-names) ", ")) #f) + ("task_id" "string" "Optional stable id making the sub-agent resumable: a later call with the same task_id continues the same conversation (prompt becomes the next user message). Required for the implementer BLUEPRINT->EXECUTE protocol." #f))) handle-task)) +(def (task-tool-description) + (string-append + "Spawn a subordinate agent to perform a focused task with its own context. " + "Use for research, codebase exploration, or any work that would otherwise bloat the main conversation. " + "Returns the sub-agent's final answer as a string. The sub-agent has access to the same tools as the parent. " + "Named agents (agent parameter): " + "delegate = read-only exploration/research; " + "doc-explorer = writes only under docs/ and plans/; " + "implementer = code changes via gated protocol — first call with task_id and a prompt starting 'MODE: BLUEPRINT' to get a step plan (no edits), review it, then call again with the SAME task_id and 'MODE: EXECUTE' plus your approval to implement and verify.")) + (def (handle-task args) - (let ((desc (hash-ref args "description" "task")) - (prompt (hash-ref args "prompt" #f)) - (system (let ((s (hash-ref args "system" #f))) - (if (eq? s (void)) #f s)))) + (let ((desc (hash-ref args "description" "task")) + (prompt (hash-ref args "prompt" #f)) + (system (opt-string args "system")) + (agent (opt-string args "agent")) + (task-id (opt-string args "task_id"))) (unless prompt (error 'task "Missing required parameter: prompt")) - (log-info logger "spawn" `((desc . ,desc))) - (let* ((wrapped (compose-prompt prompt system)) - ;; Sever streaming so sub-agent output is captured silently. - (result - (parameterize ((current-stream-cb #f) - (current-tool-cb #f) - (current-usage-cb #f)) - (try (agent-chat wrapped) - (catch (e) - (format "Sub-agent error: ~a" (err->string e))))))) - (cond - ((not result) "(sub-agent produced no content)") - ((string=? (string-trim result) "") - "(sub-agent produced no content)") - (else result))))) - -(def (compose-prompt prompt system) - ;; If `system` looks like a skill name (single word, no whitespace), - ;; try to resolve it; otherwise treat it as literal system text. - (cond - ((not system) prompt) - ((string=? system "") prompt) - ((and (not (string-contains system " ")) - (not (string-contains system "\n")) - (skill-load system)) - => (lambda (body) - (string-append body "\n\n---\n\n" prompt))) - (else - (string-append system "\n\n---\n\n" prompt)))) + (let* ((existing (and task-id (hash-get *task-sessions* task-id))) + ;; On resume, the stored agent name wins so the conversation + ;; keeps its role/scope/model — a different `agent` arg on a + ;; resume call is ignored. + (agent-name (if existing (vector-ref existing 1) agent)) + (adef (and agent-name (agent-def-lookup agent-name)))) + (when (and agent-name (not adef)) + (error 'task (format "Unknown agent '~a'. Available: ~a" + agent-name (string-join (agent-def-names) ", ")))) + (log-info logger "spawn" + `((desc . ,desc) + (agent . ,(or agent-name "-")) + (task-id . ,(or task-id "-")) + (resume . ,(and existing #t)))) + (let ((result + (with-agent-routing adef + (lambda () + (if existing + (run-resumed-task task-id existing prompt) + (run-fresh-task task-id agent-name adef prompt system)))))) + (cond + ((not result) "(sub-agent produced no content)") + ((string=? (string-trim result) "") + "(sub-agent produced no content)") + (else result)))))) + +(def (opt-string args key) + (let ((v (hash-ref args key #f))) + (if (or (eq? v (void)) (not (string? v)) (string=? v "")) #f v))) + +(def (with-agent-routing adef thunk) + "Run THUNK with provider/model/write-scope routed per ADEF (silently + capturing output — no streaming callbacks). The write scope can only + narrow: a scoped sub-agent spawning another task cannot widen access." + (let* ((aprov (and adef (agent-def-provider adef))) + (amodel (and adef (agent-def-model adef))) + (scope (write-scope-intersect + (current-write-scope) + (and adef (agent-def-write-scope adef))))) + (parameterize ((current-stream-cb #f) + (current-tool-cb #f) + (current-usage-cb #f) + (current-provider-override + (or aprov (current-provider-override))) + (current-model-override + (or amodel