Files
oh-my-pi/packages/coding-agent/src/internal-urls/agent-protocol.ts
T
roboomp 8c6b2fb450 fix(coding-agent): resolved agent:// slash form for nested subagent output
The agent:// path segment was always treated as a jq JSON-extraction key
against <parent>.md, so agent://Parent/Child loaded Parent.md and applied
.Child instead of resolving the nested child capsule Parent.Child.md. A
precise-planner reading its own scout child therefore got Not found.

The slash is now a hierarchy separator first: agent://Parent/Child resolves
Parent.Child.md (subagent children are allocated as dot-qualified ids). It
falls back to JSON extraction only when no nested output matches the path;
the ?q= query form is always extraction.

Fixes #5238
2026-07-14 17:52:04 +00:00

181 lines
6.1 KiB
TypeScript

/**
* Protocol handler for agent:// URLs.
*
* Resolves agent output IDs against the artifacts directories of every active
* session. Parents and subagents share outputs via this registry: a subagent
* can read its parent's output IDs because both sessions are registered in
* the shared context.
*
* URL forms:
* - agent://<id> - Full output content
* - agent://<id>/<child> - 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://<id>/<path> - JSON extraction via path form (fallback when no
* nested output matches the path)
* - agent://<id>?q=<query> - JSON extraction via query form
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { applyQuery, pathToQuery } from "./json-query";
import { artifactsDirsFromRegistry } from "./registry-helpers";
import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
/**
* Handler for agent:// URLs.
*
* Resolves output IDs like "reviewer_0" to their artifact files,
* with optional JSON extraction.
*/
export class AgentProtocolHandler implements ProtocolHandler {
readonly scheme = "agent";
readonly immutable = true;
async resolve(url: InternalUrl): Promise<InternalResource> {
const outputId = url.rawHost || url.hostname;
if (!outputId) {
throw new Error("agent:// URL requires an output ID: agent://<id>");
}
const urlPath = url.pathname;
const queryParam = url.searchParams.get("q");
const hasPathExtraction = urlPath && urlPath !== "/" && urlPath !== "";
const hasQueryExtraction = queryParam !== null && queryParam !== "";
if (hasPathExtraction && hasQueryExtraction) {
throw new Error("agent:// URL cannot combine path extraction with ?q=");
}
const dirs = artifactsDirsFromRegistry();
if (dirs.length === 0) {
throw new Error("No session - agent outputs unavailable");
}
// 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 `<outputId>.md`. Query form (`?q=`) is always extraction.
const pathSegments = hasPathExtraction ? urlPath.split("/").filter(Boolean) : [];
const decodedSegments = pathSegments.map(segment => {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
});
const nestedId =
decodedSegments.length > 0 && decodedSegments.every(segment => !segment.includes("."))
? [outputId, ...decodedSegments].join(".")
: undefined;
const scan = await this.#findOutput(dirs, nestedId ? [nestedId, outputId] : [outputId]);
if (!scan.anyDirExists) {
throw new Error("No artifacts directory found");
}
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(scan.foundPath).text();
const notes: string[] = [];
let content = rawContent;
let contentType: InternalResource["contentType"] = "text/markdown";
// 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 ${scan.matchedId} is not valid JSON: ${message}`);
}
const query = hasQueryExtraction ? queryParam! : pathToQuery(urlPath);
if (query) {
const extracted = applyQuery(jsonValue, query);
try {
content = JSON.stringify(extracted, null, 2) ?? "null";
} catch {
content = String(extracted);
}
notes.push(`Extracted: ${query}`);
} else {
content = JSON.stringify(jsonValue, null, 2);
}
contentType = "application/json";
}
return {
url: url.href,
content,
contentType,
size: Buffer.byteLength(content, "utf-8"),
sourcePath: scan.foundPath,
notes,
};
}
/**
* Scan every registered artifacts dir for the first `<id>.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<string> }> {
// 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<string, string>();
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<UrlCompletion[]> {
const ids = new Set<string>();
for (const dir of artifactsDirsFromRegistry()) {
let files: string[];
try {
files = await fs.readdir(dir);
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
for (const f of files) {
if (f.endsWith(".md")) ids.add(f.slice(0, -3));
}
}
return [...ids].sort().map(value => ({ value }));
}
}