diff --git a/docs/tools/task.md b/docs/tools/task.md index 4bdf27a99..1266d721f 100644 --- a/docs/tools/task.md +++ b/docs/tools/task.md @@ -68,7 +68,7 @@ Settled response (`async.enabled=false`, no job manager, every item's agent `blo Artifacts and side channels: - Every subagent with an artifacts dir writes `.md`; `agent://` resolves to that file. -- If the output file is JSON, `agent:///` and `agent://?q=` perform JSON extraction. +- A subagent's own children are dot-qualified (`.`); `agent:///` reads that nested output. When the path names no nested output and the file is JSON, `agent:///` and `agent://?q=` perform JSON extraction. - Each subagent gets `.jsonl` session history when the parent persists artifacts; `history://` renders it as a concise transcript (works for live and parked agents). - Isolated patch mode writes `.patch` before merge. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 30fc7c72b..ab90b6bc5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the `agent:///` slash form failing to resolve a nested subagent's output: the path segment was always treated as a jq JSON-extraction key against `.md`, so a precise-planner reading its own scout child (`agent://Plan/Scout`) got `Not found`. The slash is now a hierarchy separator first (`agent://Parent/Child` → `Parent.Child.md`), falling back to JSON extraction only when no nested output matches the path. ([#5238](https://github.com/can1357/oh-my-pi/issues/5238)) + ## [16.4.5] - 2026-07-11 ### Breaking Changes diff --git a/packages/coding-agent/src/internal-urls/__tests__/agent-protocol-nested.test.ts b/packages/coding-agent/src/internal-urls/__tests__/agent-protocol-nested.test.ts index 7829557f5..7108a84e8 100644 --- a/packages/coding-agent/src/internal-urls/__tests__/agent-protocol-nested.test.ts +++ b/packages/coding-agent/src/internal-urls/__tests__/agent-protocol-nested.test.ts @@ -66,3 +66,74 @@ it("agent:// resolves a depth-2 subagent's .md output while its session is live const resource = await new AgentProtocolHandler().resolve(new URL(`agent://${grandchildId}`) as never); expect(resource.content).toBe("full report content"); }); + +it("agent:// slash form resolves a nested subagent child (hierarchy separator)", async () => { + const root = tempDir.path(); + const rootSessionFile = path.join(root, "slash-session.jsonl"); + const rootArtifactsDir = rootSessionFile.slice(0, -6); + await fs.mkdir(rootArtifactsDir, { recursive: true }); + const sharedArtifactManager = new ArtifactManager(rootArtifactsDir); + + // Parent subagent adopts the root ArtifactManager; its own children are + // written one level deeper under its sessionFile-derived dir, dot-qualified. + const parentSessionFile = path.join(rootArtifactsDir, "Parent.jsonl"); + const parentOwnDir = parentSessionFile.slice(0, -6); + await fs.mkdir(parentOwnDir, { recursive: true }); + await fs.writeFile(path.join(parentOwnDir, "Parent.Child.md"), "child capsule"); + + const fakeSession = { + sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir }, + } as unknown as AgentSession; + const registry = AgentRegistry.global(); + registry.register({ + id: "Main", + displayName: "main", + kind: "main", + session: fakeSession, + sessionFile: rootSessionFile, + }); + registry.register({ + id: "Parent", + displayName: "sub", + kind: "sub", + parentId: "Main", + session: fakeSession, + sessionFile: parentSessionFile, + }); + + const handler = new AgentProtocolHandler(); + // Slash form is a hierarchy hop, not a jq extraction. + const slash = await handler.resolve(new URL("agent://Parent/Child") as never); + expect(slash.content).toBe("child capsule"); + expect(slash.contentType).toBe("text/markdown"); + // The canonical dotted id resolves to the same output. + const dotted = await handler.resolve(new URL("agent://Parent.Child") as never); + expect(dotted.content).toBe("child capsule"); +}); + +it("agent:// path form falls back to JSON extraction when no nested output matches", async () => { + const root = tempDir.path(); + const rootSessionFile = path.join(root, "json-session.jsonl"); + const rootArtifactsDir = rootSessionFile.slice(0, -6); + await fs.mkdir(rootArtifactsDir, { recursive: true }); + const sharedArtifactManager = new ArtifactManager(rootArtifactsDir); + await fs.writeFile(path.join(rootArtifactsDir, "Worker.md"), JSON.stringify({ result: { ok: true } })); + + const fakeSession = { + sessionManager: { getArtifactsDir: () => sharedArtifactManager.dir }, + } as unknown as AgentSession; + const registry = AgentRegistry.global(); + registry.register({ + id: "Main", + displayName: "main", + kind: "main", + session: fakeSession, + sessionFile: rootSessionFile, + }); + + const handler = new AgentProtocolHandler(); + // `result` names no nested output, so the path extracts JSON from Worker.md. + const extracted = await handler.resolve(new URL("agent://Worker/result") as never); + expect(extracted.contentType).toBe("application/json"); + expect(JSON.parse(extracted.content)).toEqual({ ok: true }); +}); diff --git a/packages/coding-agent/src/internal-urls/agent-protocol.ts b/packages/coding-agent/src/internal-urls/agent-protocol.ts index 00add4d66..43ebf9956 100644 --- a/packages/coding-agent/src/internal-urls/agent-protocol.ts +++ b/packages/coding-agent/src/internal-urls/agent-protocol.ts @@ -8,7 +8,11 @@ * * URL forms: * - agent:// - Full output content - * - agent:/// - JSON extraction via path form + * - agent:/// - Nested subagent output (hierarchy separator; the + * registry allocates a subagent's own children as dot-qualified ids, so + * `agent://Parent/Child` resolves `Parent.Child.md`) + * - agent:/// - JSON extraction via path form (fallback when no + * nested output matches the path) * - agent://?q= - JSON extraction via query form */ import * as fs from "node:fs/promises"; @@ -44,65 +48,56 @@ export class AgentProtocolHandler implements ProtocolHandler { } const dirs = artifactsDirsFromRegistry(); - if (dirs.length === 0) { throw new Error("No session - agent outputs unavailable"); } - let foundPath: string | undefined; - let anyDirExists = false; - const availableIds = new Set(); - - for (const dir of dirs) { + // A subagent allocates its own children as dot-qualified ids + // (`Parent.Child`), so the slash path form is first tried as a hierarchy + // separator: `agent://Parent/Child` resolves `Parent.Child.md`. Only when + // no such nested output exists does the path fall back to jq-style JSON + // extraction on `.md`. Query form (`?q=`) is always extraction. + const pathSegments = hasPathExtraction ? urlPath.split("/").filter(Boolean) : []; + const decodedSegments = pathSegments.map(segment => { try { - await fs.stat(dir); - anyDirExists = true; - } catch (err) { - if (isEnoent(err)) continue; - throw err; + return decodeURIComponent(segment); + } catch { + return segment; } - const candidate = path.join(dir, `${outputId}.md`); - try { - await fs.stat(candidate); - foundPath = candidate; - break; - } catch (err) { - if (!isEnoent(err)) throw err; - try { - const files = await fs.readdir(dir); - for (const f of files) { - if (f.endsWith(".md")) availableIds.add(f.replace(/\.md$/, "")); - } - } catch { - // Listing failures are non-fatal; continue searching. - } - } - } + }); + const nestedId = + decodedSegments.length > 0 && decodedSegments.every(segment => !segment.includes(".")) + ? [outputId, ...decodedSegments].join(".") + : undefined; - if (!anyDirExists) { + const scan = await this.#findOutput(dirs, nestedId ? [nestedId, outputId] : [outputId]); + if (!scan.anyDirExists) { throw new Error("No artifacts directory found"); } - - if (!foundPath) { - const availableStr = availableIds.size > 0 ? [...availableIds].join(", ") : "none"; - throw new Error(`Not found: ${outputId}\nAvailable: ${availableStr}`); + if (!scan.foundPath) { + const target = nestedId ?? outputId; + const availableStr = scan.availableIds.size > 0 ? [...scan.availableIds].join(", ") : "none"; + throw new Error(`Not found: ${target}\nAvailable: ${availableStr}`); } - const rawContent = await Bun.file(foundPath).text(); + const rawContent = await Bun.file(scan.foundPath).text(); const notes: string[] = []; let content = rawContent; let contentType: InternalResource["contentType"] = "text/markdown"; - if (hasPathExtraction || hasQueryExtraction) { + // Extraction applies only when the URL did NOT resolve to a nested output + // (a slash that named a real child is a hierarchy hop, not a jq path). + const extract = hasQueryExtraction || (hasPathExtraction && scan.matchedId !== nestedId); + if (extract) { let jsonValue: unknown; try { jsonValue = JSON.parse(rawContent); } catch (err) { const message = err instanceof Error ? err.message : String(err); - throw new Error(`Output ${outputId} is not valid JSON: ${message}`); + throw new Error(`Output ${scan.matchedId} is not valid JSON: ${message}`); } - const query = hasPathExtraction ? pathToQuery(urlPath) : queryParam!; + const query = hasQueryExtraction ? queryParam! : pathToQuery(urlPath); if (query) { const extracted = applyQuery(jsonValue, query); try { @@ -122,11 +117,50 @@ export class AgentProtocolHandler implements ProtocolHandler { content, contentType, size: Buffer.byteLength(content, "utf-8"), - sourcePath: foundPath, + sourcePath: scan.foundPath, notes, }; } + /** + * Scan every registered artifacts dir for the first `.md` among + * `candidateIds` (tried in order, so a hierarchy match wins over the base + * id). Returns the resolved path and the id it matched, plus the set of + * available ids gathered from the scanned dirs for the not-found message. + */ + async #findOutput( + dirs: string[], + candidateIds: string[], + ): Promise<{ foundPath?: string; matchedId?: string; anyDirExists: boolean; availableIds: Set }> { + // Build a full id→path map across every registered dir before picking, so + // candidate priority is global: a nested id in a deeper dir must win over + // the base id even when the base id's dir is scanned first. + const byId = new Map(); + let anyDirExists = false; + for (const dir of dirs) { + let files: string[]; + try { + files = await fs.readdir(dir); + } catch (err) { + if (isEnoent(err)) continue; + throw err; + } + anyDirExists = true; + for (const f of files) { + if (!f.endsWith(".md")) continue; + const id = f.slice(0, -3); + if (!byId.has(id)) byId.set(id, path.join(dir, f)); + } + } + for (const id of candidateIds) { + const foundPath = byId.get(id); + if (foundPath) { + return { foundPath, matchedId: id, anyDirExists, availableIds: new Set(byId.keys()) }; + } + } + return { anyDirExists, availableIds: new Set(byId.keys()) }; + } + async complete(): Promise { const ids = new Set(); for (const dir of artifactsDirsFromRegistry()) { diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 1e45733ed..1ee689faa 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -56,7 +56,7 @@ Special URLs for internal resources; with most FS/bash tools they auto-resolve t {{#if hasMemoryRoot}} - `memory://root`: project memory summary {{/if}} -- `agent://`: agent output artifact; `/` extracts a JSON field +- `agent://`: agent output artifact; `/` reads a nested subagent's output, else `/` extracts a JSON field - `artifact://`: artifact content - `local://.md`: plan artifacts or shared content for subagents {{#if hasObsidian}}