Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1210,7 +1210,7 @@ goaly runs list --workspace ./myrepo # look elsewhere for the .goaly directory
stuck/failure **reason**, and the totals.
- **`goaly runs resume-cmd <runId>`** — print the command to continue the run's underlying CLI
session in its **own interactive mode** (e.g. `claude --resume <id>`, `codex resume <id>`,
`droid --session-id <id>`, `pi --continue`), recovered from the log's recorded harness + last real
`droid --resume <id>`, `pi --continue`), recovered from the log's recorded harness + last real
session id. For a `goaly-code` run (no external CLI) it routes you to `--from-run --inherit-session`.
Pass `--harness <name>` as a fallback when the log predates harness recording.

Expand Down
14 changes: 9 additions & 5 deletions docs/adding-a-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ import { parseAgentOutput, flatExtractor, type FieldExtractor } from './output';
// If your envelope is FLAT (text under result/text/response, session under session_id/sessionId,
// tokens in a `usage` block — input/output AND cache_read/cache_creation are read for you) you
// don't write one at all — reuse the shared factory:
const fieldExtractor = flatExtractor(); // claude & droid use exactly this
// droid only adds a soft-error flag: flatExtractor({ errorKey: 'is_error' })
const fieldExtractor = flatExtractor(); // claude uses exactly this
// droid wraps flatExtractor({ errorKey: 'is_error' }) in a line-type GATE: its stream-json reuses
// `text` on non-result lines (the user prompt echo, reasoning), and its closing `completion` line
// carries the result as `finalText` — see droid-codec.ts for the pattern.

// Write a CUSTOM extractor only if your shapes are nested (see `codexExtractor` in codex-codec.ts,
// which walks message/content[]/delta and thread-id session keys):
Expand Down Expand Up @@ -262,8 +264,10 @@ a throw degrades to "no events for this line" (fail-closed; observability never
import { sdkStreamExtractor, flatStreamExtractor, type StreamEventExtractor } from './stream';

// If your tool emits the ANTHROPIC AGENT-SDK stream-json envelope (system/assistant/user/result
// events) you don't write one at all — reuse the shared factory (claude & droid use exactly this):
const streamExtractor = sdkStreamExtractor(); // droid adds { errorKey: 'is_error' }
// events) you don't write one at all — reuse the shared factory (claude uses exactly this):
const streamExtractor = sdkStreamExtractor();
// droid's stream-json is droid-NATIVE (message/reasoning/tool_call/tool_result/completion lines,
// verified live on 0.164.0), so its codec maps a custom extractor — see droid-codec.ts.

// If your tool only emits a single FINAL result object, degrade with the flat factory
// (session → message → usage → done):
Expand Down Expand Up @@ -303,7 +307,7 @@ interactiveResume(id) {
```

The four bundled codecs map it as: claude → `claude --resume <id>`; codex → `codex resume <id>`
(caveat: differs from `exec`); droid → `droid --session-id <id>`; pi → `pi --continue` (caveat: it
(caveat: differs from `exec`); droid → `droid --resume <id>`; pi → `pi --continue` (caveat: it
resumes the latest *cwd* session only, so the id is not addressable). It is **optional** — a codec
that omits it makes `resume-cmd` report a typed "no resume command" for that harness. The
`HarnessChoice`→codec bridge and the typed result live in `src/cli/resume-cmd.ts` (`resumeHint`);
Expand Down
2 changes: 1 addition & 1 deletion src/agent-cli/codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('AgentCliCodec argv dialects', () => {
expect(
makeDroidCodec('medium').harnessArgs({ prompt: 'go', model: 'm1', sessionId: sid('s'), stream: true }),
).toEqual([
'exec', '--output-format', 'stream-json', '--auto', 'medium', '--model', 'm1', '--session-id', 's', 'go',
'exec', '--output-format', 'stream-json', '--auto', 'medium', '--model', 'm1', '--fork', 's', 'go',
]);
});

Expand Down
55 changes: 51 additions & 4 deletions src/agent-cli/droid-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function fakeExec(
};
}

/** A real `droid exec --output-format json` envelope captured from droid 0.153.1. */
/** A real `droid exec --output-format json` envelope captured from droid 0.164.0. */
const droidRealSample = JSON.stringify({
type: 'result',
subtype: 'success',
Expand All @@ -34,6 +34,26 @@ const droidRealSample = JSON.stringify({
},
});

/**
* A real `droid exec --output-format stream-json` transcript captured from droid 0.164.0 (trimmed:
* long paths/tool lists shortened; keys and line shapes verbatim). Note the droid-NATIVE envelope —
* message/reasoning/tool_call/tool_result/completion — not the Anthropic agent-SDK one, and the
* duplicated reasoning line, which droid really emits.
*/
const SID = '11222393-d4e2-464e-bd63-eb6b96d7a40e';
const droidStreamLines = [
{ type: 'system', subtype: 'init', cwd: '/tmp/probe', session_id: SID, tools: ['Read', 'Execute', 'Create'], model: 'claude-opus-4-8', reasoning_effort: 'high' },
{ type: 'message', role: 'user', id: 'f8401e2e', text: 'create a file named probe.txt containing the word hello, then report done', timestamp: 1783373407509, session_id: SID },
{ type: 'reasoning', id: '5a9e1859', text: 'The user wants me to create a simple file named probe.txt.', timestamp: 1783373409985, session_id: SID },
{ type: 'reasoning', id: '5a9e1859', text: 'The user wants me to create a simple file named probe.txt.', timestamp: 1783373409985, session_id: SID },
{ type: 'tool_call', id: 'call_4wv2tm9k', messageId: '5a9e1859', toolId: 'Create', toolName: 'Create', parameters: { file_path: '/tmp/probe/probe.txt', content: 'hello' }, timestamp: 1783373409985, session_id: SID },
{ type: 'tool_result', id: 'call_4wv2tm9k', messageId: 'ff11ae55', toolId: 'Create', isError: false, value: '{"success":true,"file_path":"/tmp/probe/probe.txt"}', timestamp: 1783373410009, session_id: SID },
{ type: 'message', role: 'assistant', id: 'f213682c', text: 'Done. Created `probe.txt` containing the word "hello".', timestamp: 1783373411553, session_id: SID },
{ type: 'completion', finalText: 'Done. Created `probe.txt` containing the word "hello".', numTurns: 2, durationMs: 4074, session_id: SID, timestamp: 1783373411556, usage: { input_tokens: 30671, output_tokens: 116, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } },
];
const droidStreamStdout = (n = droidStreamLines.length) =>
droidStreamLines.slice(0, n).map((l) => JSON.stringify(l)).join('\n');

describe('droidCodec.parse', () => {
it('extracts result text, session id, and summed tokens from a real envelope', () => {
const parsed = droidCodec.parse(droidRealSample);
Expand Down Expand Up @@ -89,6 +109,33 @@ describe('droidCodec.parse', () => {
});
});

describe('droidCodec.parse (native stream-json transcript, captured 0.164.0)', () => {
it('recovers finalText from the completion line, with the session and full usage', () => {
expect(droidCodec.parse(droidStreamStdout())).toEqual({
text: 'Done. Created `probe.txt` containing the word "hello".',
sessionId: SID,
tokens: 30787,
breakdown: { input: 30671, output: 116, cacheRead: 0, cacheWrite: 0 },
});
});

it('never salvages the echoed USER prompt as result text (stream cut after the echo)', () => {
// system + user-message lines only: a run that died right after starting.
expect(droidCodec.parse(droidStreamStdout(2))).toBeNull();
});

it('does not treat reasoning or tool traffic as result text', () => {
// through system, user echo, both reasoning lines, tool_call, tool_result — still no result.
expect(droidCodec.parse(droidStreamStdout(6))).toBeNull();
});

it('salvages the last assistant message when the stream dies before completion', () => {
const parsed = droidCodec.parse(droidStreamStdout(7));
expect(parsed?.text).toBe('Done. Created `probe.txt` containing the word "hello".');
expect(parsed?.sessionId).toBe(SID);
});
});

describe('AgentCliHarness(droidCodec)', () => {
it('exposes the harness name', () => {
expect(new AgentCliHarness(droidCodec).name).toBe('droid');
Expand All @@ -111,14 +158,14 @@ describe('AgentCliHarness(droidCodec)', () => {
]);
});

it('adds --session-id <id> when resuming, keeping the prompt last', async () => {
it('resumes via --fork <id> (exec -s fetches remotely and fails for local sessions), prompt last', async () => {
const capture = { args: [] as string[][], prompts: [] as string[] };
const adapter = new AgentCliHarness(droidCodec, { exec: fakeExec({ stdout: droidRealSample, stderr: '', code: 0 }, capture) });

await adapter.run('continue', SessionId.parse('sess-prev'));

expect(capture.args[0]).toEqual([
'exec', '--output-format', 'json', '--auto', 'low', '--session-id', 'sess-prev', 'continue',
'exec', '--output-format', 'json', '--auto', 'low', '--fork', 'sess-prev', 'continue',
]);
});

Expand Down Expand Up @@ -149,7 +196,7 @@ describe('AgentCliHarness(droidCodec)', () => {

await adapter.run('more', SessionId.parse('sess-prev'));
expect(capture.args[1]).toEqual([
'exec', '--output-format', 'json', '--auto', 'low', '--model', 'm1', '--session-id', 'sess-prev', 'more',
'exec', '--output-format', 'json', '--auto', 'low', '--model', 'm1', '--fork', 'sess-prev', 'more',
]);
});

Expand Down
133 changes: 117 additions & 16 deletions src/agent-cli/droid-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,37 @@
* The Factory `droid` codec — all of `droid`'s per-CLI knowledge in one place
* (see {@link AgentCliCodec}). https://docs.factory.ai/cli
*
* Assumed CLI contract (verified against droid 0.153.1 — the EXACT flags may drift between versions;
* this is the seam, not a hard dependency):
* harness (write): droid exec --output-format json --auto <level> [--model <m>] [--session-id <id>] "<prompt>"
* Assumed CLI contract (verified LIVE against droid 0.164.0 — the launcher self-updates, so the
* engine version is what matters; the EXACT flags may drift between versions):
* harness (write): droid exec --output-format json --auto <level> [--model <m>] [--fork <id>] "<prompt>"
* provider (read): droid exec --output-format json [--model <m>] "<prompt>" (no --auto)
*
* droid emits the Anthropic agent-SDK envelope, so its final-result parse reuses the flat
* {@link flatExtractor} (with droid's `is_error` soft-error key) and its stream mapping reuses the
* shared {@link sdkStreamExtractor} — droid and claude share one stream mapping. Streaming
* swaps `--output-format json` → `stream-json` for the per-turn JSONL.
* FINAL envelope (`--output-format json`): one flat object — `result` / `session_id` / `usage`
* (input/output + both cache buckets) / `is_error` — so the final-result parse builds on the shared
* {@link flatExtractor}.
*
* STREAMING (`--output-format stream-json`) is droid-NATIVE, **not** the Anthropic agent-SDK
* envelope (observed live on 0.164.0; older docs assumed the SDK shape):
* {type:"system",subtype:"init",session_id,...} → session
* {type:"message",role:"user"|"assistant",text} → message (assistant ONLY — the user
* line echoes the prompt back)
* {type:"reasoning",text} → reasoning (droid may emit the same
* line twice; mapped as-is)
* {type:"tool_call",id,toolName,parameters} → tool_use
* {type:"tool_result",id,value,isError} → tool_result
* {type:"completion",finalText,usage,session_id} → usage + done (finalText is the result)
* The field extractor is therefore type-aware: it must NOT treat the user echo or reasoning lines
* as result text (a stream truncated right after the echo would otherwise "complete" with the
* caller's own prompt as output), and it reads `finalText` from the closing `completion` line.
*
* SESSION CONTINUITY is via `--fork <id>`, not `--session-id <id>`: on droid 0.164.0,
* `exec -s <id>` fetches the session from Factory's backend and FAILS for locally-created exec
* sessions ("Failed to fetch session" — a silent exit 1 with empty stdout/stderr; the error only
* lands in `~/.factory/logs`). `--fork <id>` loads the LOCAL transcript and continues it under a
* fresh session id (verified live: the forked turn recalls the prior conversation). The envelope
* returns the newly-minted id and goaly threads whatever id each run reports, so multi-iteration
* continuity is a fork CHAIN — no consumer assumes session-id stability. Had we kept `-s`, every
* continuation turn would crash and the loop would re-thread the same dead id (crash loop).
*
* Autonomy: `droid exec` defaults to READ-ONLY (cannot modify files) — useless for a goaly loop — so
* the harness role always passes `--auto`. The default is `low` (file create/modify only, no
Expand All @@ -21,8 +43,8 @@
* mutate the tree it is judging. We never pass `--skip-permissions-unsafe`.
*/

import { parseAgentOutput, flatExtractor } from './output';
import { sdkStreamExtractor } from './stream';
import { parseAgentOutput, flatExtractor, isRecord, type FieldExtractor } from './output';
import { usageEventFromBlock, type AgentStreamEvent, type StreamEventExtractor } from './stream';
import { classifyFlatRun, type AgentCliCodec } from './codec';

/** Autonomy tiers `droid exec` accepts via `--auto`. */
Expand All @@ -33,11 +55,88 @@ export const DEFAULT_AUTONOMY: AutonomyLevel = 'low';

const UNKNOWN_SESSION = 'droid-unknown';

/** Field strategy for droid's flat result envelope (result/session_id/usage/is_error). */
const fieldExtractor = flatExtractor({ errorKey: 'is_error' });
/** The flat strategy covers the plain `json` envelope; the droid extractor gates it per line type. */
const flat = flatExtractor({ errorKey: 'is_error' });

/**
* Field strategy for droid's envelopes — the flat mapping, made STREAM-AWARE. The stream-json lines
* reuse generic key names for non-result content (`text` on the user prompt echo and on reasoning
* lines), so the extractor gates on `type`: only assistant messages and the closing `completion`
* line (via `finalText`) may bear result text. Everything else still contributes session id / usage.
*/
const fieldExtractor: FieldExtractor = (obj) => {
const fields = flat(obj);
const type = obj['type'];
if (type === 'message' && obj['role'] !== 'assistant') {
delete fields.text; // the stream echoes the USER prompt as a message line — never result text
} else if (type === 'reasoning' || type === 'tool_call' || type === 'tool_result') {
delete fields.text; // thinking / tool traffic, not a result
} else if (type === 'completion' && typeof obj['finalText'] === 'string') {
fields.text = obj['finalText']; // the stream's closing line carries the result as `finalText`
}
return fields;
};

/** droid's STREAM mapping (the shared Anthropic agent-SDK mapping, with droid's `is_error` key). */
const streamExtractor = sdkStreamExtractor({ errorKey: 'is_error' });
/** droid's native stream-json mapping onto the canonical {@link AgentStreamEvent} taxonomy. */
const streamExtractor: StreamEventExtractor = (obj) => {
const type = typeof obj['type'] === 'string' ? obj['type'] : '';
if (type === 'system') {
// Only the init line announces the session; suppress any other system subtypes (telemetry).
const subtype = obj['subtype'];
if (typeof subtype === 'string' && subtype !== 'init') return [];
const sid = obj['session_id'] ?? obj['sessionId'];
return typeof sid === 'string' ? [{ kind: 'session', sessionId: sid }] : [];
}
if (type === 'message') {
// The user line echoes the prompt back — only assistant text is agent output.
if (obj['role'] !== 'assistant' || typeof obj['text'] !== 'string') return [];
return [{ kind: 'message', text: obj['text'] }];
}
if (type === 'reasoning') {
return typeof obj['text'] === 'string' ? [{ kind: 'reasoning', text: obj['text'] }] : [];
}
if (type === 'tool_call') {
const name = typeof obj['toolName'] === 'string' ? obj['toolName'] : undefined;
if (name === undefined) return [];
const id = typeof obj['id'] === 'string' ? obj['id'] : undefined;
return [
{
kind: 'tool_use',
...(id !== undefined ? { id } : {}),
name,
...(obj['parameters'] !== undefined ? { input: obj['parameters'] } : {}),
},
];
}
if (type === 'tool_result') {
const id = typeof obj['id'] === 'string' ? obj['id'] : undefined;
const value = obj['value'];
const output =
typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value);
return [
{
kind: 'tool_result',
...(id !== undefined ? { id } : {}),
output,
...(typeof obj['isError'] === 'boolean' ? { isError: obj['isError'] } : {}),
},
];
}
if (type === 'completion' || type === 'result') {
// `completion` closes a stream-json run; a plain-`json` `result` envelope maps the same way.
const events: AgentStreamEvent[] = [];
const usage = obj['usage'];
if (isRecord(usage)) {
const u = usageEventFromBlock(usage);
if (u !== null) events.push(u);
}
const isError = obj['is_error'] === true || obj['isError'] === true;
const subtype = typeof obj['subtype'] === 'string' ? obj['subtype'] : 'completed';
events.push({ kind: 'done', status: isError ? 'error' : subtype });
return events;
}
return [];
};

/**
* Build a droid codec for a given autonomy level (the only per-instance knob). The extractors,
Expand All @@ -54,10 +153,11 @@ export function makeDroidCodec(auto: AutonomyLevel = DEFAULT_AUTONOMY): AgentCli
streamExtractor,
harnessArgs({ prompt, model, sessionId, stream }) {
// Flags first, prompt last (so a prompt is never mistaken for a flag value). A sessionId is a
// branded, allowlisted string (it can never begin with `-`), so `--session-id <id>` is safe.
// branded, allowlisted string (it can never begin with `-`), so `--fork <id>` is safe.
// Continuity is `--fork`, NOT `--session-id` — see the header (0.164.0 resume regression).
const args = ['exec', '--output-format', stream ? 'stream-json' : 'json', '--auto', auto];
if (model !== undefined) args.push('--model', model);
if (sessionId !== undefined) args.push('--session-id', sessionId);
if (sessionId !== undefined) args.push('--fork', sessionId);
args.push(prompt);
return args;
},
Expand All @@ -79,7 +179,8 @@ export function makeDroidCodec(auto: AutonomyLevel = DEFAULT_AUTONOMY): AgentCli
});
},
interactiveResume(id) {
return { command: `droid --session-id ${id}` };
// The documented top-level flag is `-r/--resume <id>` (`--session-id` exists only on `exec`).
return { command: `droid --resume ${id}` };
},
};
}
Expand Down
8 changes: 4 additions & 4 deletions src/agent-cli/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,10 @@ function flatUsage(
}

/**
* The claude/droid strategy: a flat envelope with the text under `result`/`text`/`response`, the
* session under `session_id`/`sessionId`, tokens in a `usage` block, and (droid only) a boolean
* soft-error key. Both CLIs share this shape, so one factory serves both — pass `errorKey` for
* droid's `is_error`.
* The flat-envelope strategy: text under `result`/`text`/`response`, the session under
* `session_id`/`sessionId`, tokens in a `usage` block, and an optional boolean soft-error key
* (droid's `is_error`). Claude uses it as-is; droid LAYERS a line-type gate on top (its stream-json
* reuses `text` for non-result lines — see droid-codec.ts).
*/
export function flatExtractor(opts: { errorKey?: string } = {}): FieldExtractor {
return (obj) => {
Expand Down
Loading
Loading