@relayflows/sdk 2.0.21 → 2.0.23
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.
- package/dist/agent-artifacts.d.ts +18 -9
- package/dist/agent-artifacts.d.ts.map +1 -1
- package/dist/agent-artifacts.js +27 -11
- package/dist/agent-artifacts.js.map +1 -1
- package/dist/agent-transcript.d.ts +186 -0
- package/dist/agent-transcript.d.ts.map +1 -0
- package/dist/agent-transcript.js +585 -0
- package/dist/agent-transcript.js.map +1 -0
- package/dist/authored-flow-error.d.ts +33 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +20 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +4 -1
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-node-entry.js +5 -1
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts +11 -0
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +30 -1
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.js +3 -1
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-source-authority.js +13 -0
- package/dist/authored-source-authority.js.map +1 -1
- package/dist/authored-step-index.d.ts +82 -0
- package/dist/authored-step-index.d.ts.map +1 -0
- package/dist/authored-step-index.js +120 -0
- package/dist/authored-step-index.js.map +1 -0
- package/dist/authored-step-output.d.ts +23 -2
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +47 -5
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts +10 -2
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +81 -7
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +67 -12
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-read.d.ts +31 -0
- package/dist/cli/cloud-read.d.ts.map +1 -0
- package/dist/cli/cloud-read.js +482 -0
- package/dist/cli/cloud-read.js.map +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +53 -24
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/status.d.ts +43 -0
- package/dist/cli/status.d.ts.map +1 -0
- package/dist/cli/status.js +321 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/step-failure.d.ts +26 -0
- package/dist/cli/step-failure.d.ts.map +1 -1
- package/dist/cli/step-failure.js +99 -10
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +41 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +33 -0
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +4 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-http.d.ts +12 -0
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +14 -6
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-read.d.ts +154 -0
- package/dist/cloud-read.d.ts.map +1 -0
- package/dist/cloud-read.js +373 -0
- package/dist/cloud-read.js.map +1 -0
- package/dist/cloud-transcript.d.ts +94 -0
- package/dist/cloud-transcript.d.ts.map +1 -0
- package/dist/cloud-transcript.js +342 -0
- package/dist/cloud-transcript.js.map +1 -0
- package/dist/communication/environment.d.ts +3 -0
- package/dist/communication/environment.d.ts.map +1 -0
- package/dist/communication/environment.js +34 -0
- package/dist/communication/environment.js.map +1 -0
- package/dist/communication/history.d.ts +6 -0
- package/dist/communication/history.d.ts.map +1 -0
- package/dist/communication/history.js +38 -0
- package/dist/communication/history.js.map +1 -0
- package/dist/communication/local.d.ts +6 -0
- package/dist/communication/local.d.ts.map +1 -0
- package/dist/communication/local.js +56 -0
- package/dist/communication/local.js.map +1 -0
- package/dist/communication/preflight.d.ts +6 -0
- package/dist/communication/preflight.d.ts.map +1 -0
- package/dist/communication/preflight.js +37 -0
- package/dist/communication/preflight.js.map +1 -0
- package/dist/communication/projection.d.ts +8 -0
- package/dist/communication/projection.d.ts.map +1 -0
- package/dist/communication/projection.js +23 -0
- package/dist/communication/projection.js.map +1 -0
- package/dist/communication/relay.d.ts +78 -0
- package/dist/communication/relay.d.ts.map +1 -0
- package/dist/communication/relay.js +89 -0
- package/dist/communication/relay.js.map +1 -0
- package/dist/communication/session.d.ts +24 -0
- package/dist/communication/session.d.ts.map +1 -0
- package/dist/communication/session.js +100 -0
- package/dist/communication/session.js.map +1 -0
- package/dist/communication/spec.d.ts +20 -0
- package/dist/communication/spec.d.ts.map +1 -0
- package/dist/communication/spec.js +113 -0
- package/dist/communication/spec.js.map +1 -0
- package/dist/communication/tool.d.ts +3 -0
- package/dist/communication/tool.d.ts.map +1 -0
- package/dist/communication/tool.js +23 -0
- package/dist/communication/tool.js.map +1 -0
- package/dist/communication/tools.d.ts +11 -0
- package/dist/communication/tools.d.ts.map +1 -0
- package/dist/communication/tools.js +65 -0
- package/dist/communication/tools.js.map +1 -0
- package/dist/communication/worker.d.ts +6 -0
- package/dist/communication/worker.d.ts.map +1 -0
- package/dist/communication/worker.js +120 -0
- package/dist/communication/worker.js.map +1 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +8 -1
- package/dist/compile.js.map +1 -1
- package/dist/failure-kinds.d.ts +30 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +9 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +4 -1
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +11 -2
- package/dist/journal-client.js.map +1 -1
- package/dist/journal-reader.d.ts +1 -1
- package/dist/journal-reader.d.ts.map +1 -1
- package/dist/journal-reader.js +3 -1
- package/dist/journal-reader.js.map +1 -1
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +12 -1
- package/dist/llm-worker.js.map +1 -1
- package/dist/permissions-preflight.d.ts +22 -0
- package/dist/permissions-preflight.d.ts.map +1 -0
- package/dist/permissions-preflight.js +49 -0
- package/dist/permissions-preflight.js.map +1 -0
- package/dist/preflight.d.ts +13 -2
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +29 -6
- package/dist/preflight.js.map +1 -1
- package/dist/protocol.d.ts +39 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/pty-sidechannel.d.ts +7 -0
- package/dist/pty-sidechannel.d.ts.map +1 -1
- package/dist/pty-sidechannel.js.map +1 -1
- package/dist/redact.d.ts +16 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +104 -0
- package/dist/redact.js.map +1 -0
- package/dist/run-state.d.ts +124 -0
- package/dist/run-state.d.ts.map +1 -0
- package/dist/run-state.js +368 -0
- package/dist/run-state.js.map +1 -0
- package/dist/spec.d.ts +24 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-env.d.ts +25 -0
- package/dist/step-env.d.ts.map +1 -0
- package/dist/step-env.js +34 -0
- package/dist/step-env.js.map +1 -0
- package/dist/transcript-tail.d.ts +54 -0
- package/dist/transcript-tail.d.ts.map +1 -0
- package/dist/transcript-tail.js +176 -0
- package/dist/transcript-tail.js.map +1 -0
- package/dist/worker-cli.d.ts +10 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +138 -17
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-usage.d.ts +7 -2
- package/dist/worker-usage.d.ts.map +1 -1
- package/dist/worker-usage.js +21 -3
- package/dist/worker-usage.js.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +41 -7
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-runtime.d.ts +7 -1
- package/dist/wrapper-runtime.d.ts.map +1 -1
- package/dist/wrapper-runtime.js +7 -1
- package/dist/wrapper-runtime.js.map +1 -1
- package/dist/wrapper-session.d.ts +8 -0
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +156 -23
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +14 -2
- package/src/agent-artifacts.ts +27 -11
- package/src/agent-transcript.ts +651 -0
- package/src/authored-flow-error.ts +17 -0
- package/src/authored-flow-executor.ts +4 -1
- package/src/authored-node-entry.ts +5 -1
- package/src/authored-node-runner.ts +33 -1
- package/src/authored-root.ts +2 -1
- package/src/authored-source-authority.ts +13 -0
- package/src/authored-step-index.ts +157 -0
- package/src/authored-step-output.ts +66 -14
- package/src/authored-worker-step.ts +88 -15
- package/src/cli/check.ts +69 -11
- package/src/cli/cloud-read.ts +518 -0
- package/src/cli/run.ts +43 -24
- package/src/cli/status.ts +349 -0
- package/src/cli/step-failure.ts +100 -10
- package/src/cli-commands.ts +33 -0
- package/src/cli.ts +27 -0
- package/src/cloud-http.ts +32 -6
- package/src/cloud-read.ts +515 -0
- package/src/cloud-transcript.ts +422 -0
- package/src/communication/environment.ts +33 -0
- package/src/communication/history.ts +38 -0
- package/src/communication/local.ts +41 -0
- package/src/communication/preflight.ts +34 -0
- package/src/communication/projection.ts +23 -0
- package/src/communication/relay.ts +102 -0
- package/src/communication/session.ts +84 -0
- package/src/communication/spec.ts +106 -0
- package/src/communication/tool.ts +22 -0
- package/src/communication/tools.ts +50 -0
- package/src/communication/worker.ts +95 -0
- package/src/compile.ts +4 -1
- package/src/failure-kinds.ts +31 -1
- package/src/index.ts +10 -1
- package/src/journal-client.ts +12 -2
- package/src/journal-reader.ts +4 -2
- package/src/llm-worker.ts +12 -1
- package/src/permissions-preflight.ts +53 -0
- package/src/preflight.ts +42 -7
- package/src/protocol.ts +15 -0
- package/src/pty-sidechannel.ts +7 -0
- package/src/redact.ts +106 -0
- package/src/run-state.ts +468 -0
- package/src/spec.ts +18 -1
- package/src/step-env.ts +47 -0
- package/src/transcript-tail.ts +193 -0
- package/src/worker-cli.ts +139 -18
- package/src/worker-usage.ts +26 -3
- package/src/worker.ts +42 -6
- package/src/wrapper-runtime.ts +7 -1
- package/src/wrapper-session.ts +159 -24
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Bounded transcript tails: the last 64 KiB of an agent attempt's stdout and
|
|
2
|
+
// stderr, on disk beside its `pty.sock`.
|
|
3
|
+
//
|
|
4
|
+
// Nothing else holds the transcript. The worker buffers it in memory and
|
|
5
|
+
// publishes it live to the sidechannel; a failed agent attempt's output is
|
|
6
|
+
// nulled in the journal (machine.rs), leaving at most 2,000 chars of gate
|
|
7
|
+
// `detail`. So "why did my last attempt end" was unanswerable from disk.
|
|
8
|
+
//
|
|
9
|
+
// These files are evidence, not the record: best-effort, bounded, rewritten
|
|
10
|
+
// in place, never a reason for a step to fail. The journal remains truth.
|
|
11
|
+
// The file is raw; `flows status` redacts on read, which is the same trust
|
|
12
|
+
// boundary as `pty.sock` today (same OS user, mode 0600).
|
|
13
|
+
|
|
14
|
+
import { mkdir, open, readFile, rename } from 'node:fs/promises';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { ptySocketPath } from './pty-sidechannel.js';
|
|
17
|
+
import { redact } from './redact.js';
|
|
18
|
+
import type { StepTails, TailSource, TranscriptTail } from './cli/status.js';
|
|
19
|
+
import type { StepView } from './run-state.js';
|
|
20
|
+
|
|
21
|
+
export const TAIL_CAPACITY_BYTES = 64 * 1024;
|
|
22
|
+
/** At most four rewrites a second, however fast the agent talks. */
|
|
23
|
+
export const TAIL_FLUSH_INTERVAL_MS = 250;
|
|
24
|
+
/**
|
|
25
|
+
* How long a spawn's completion will wait for the final tail flush. Transcript
|
|
26
|
+
* evidence is best-effort, so a stalled filesystem must cost the step this
|
|
27
|
+
* much and no more — past it the invocation settles and the close runs on.
|
|
28
|
+
*/
|
|
29
|
+
export const TAIL_CLOSE_TIMEOUT_MS = 2_000;
|
|
30
|
+
|
|
31
|
+
export type TailStream = 'stdout' | 'stderr';
|
|
32
|
+
|
|
33
|
+
export interface TailIdentity {
|
|
34
|
+
dataDir: string;
|
|
35
|
+
runId: string;
|
|
36
|
+
stepId: string;
|
|
37
|
+
attempt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The first line of every tail file, so a stale file is never mistaken for this attempt's. */
|
|
41
|
+
export interface TailHeader {
|
|
42
|
+
v: 1;
|
|
43
|
+
run_id: string;
|
|
44
|
+
step_id: string;
|
|
45
|
+
attempt: number;
|
|
46
|
+
started_at_ms: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function transcriptTailPath(identity: TailIdentity, stream: TailStream): string {
|
|
50
|
+
if (!Number.isSafeInteger(identity.attempt) || identity.attempt < 0) throw new Error('Invalid transcript tail attempt');
|
|
51
|
+
return join(dirname(ptySocketPath(identity)), `attempt-${identity.attempt}.${stream}.tail`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TranscriptTailWriter {
|
|
55
|
+
append(chunk: Buffer): void;
|
|
56
|
+
/** Flush what is pending and stop. Resolves once the last write has settled, success or not. */
|
|
57
|
+
close(): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A ring of the last {@link TAIL_CAPACITY_BYTES} bytes, flushed to
|
|
62
|
+
* `attempt-<n>.<stream>.tail` at most every {@link TAIL_FLUSH_INTERVAL_MS}.
|
|
63
|
+
* A write failure is reported once as a process warning and then ignored:
|
|
64
|
+
* the attempt continues, and `flows status` will say no transcript is on disk.
|
|
65
|
+
*/
|
|
66
|
+
export function openTranscriptTail(
|
|
67
|
+
identity: TailIdentity,
|
|
68
|
+
stream: TailStream,
|
|
69
|
+
startedAtMs = Date.now(),
|
|
70
|
+
warn: (message: string) => void = (message) => process.emitWarning(message),
|
|
71
|
+
): TranscriptTailWriter {
|
|
72
|
+
const path = transcriptTailPath(identity, stream);
|
|
73
|
+
const header = JSON.stringify({ v: 1, run_id: identity.runId, step_id: identity.stepId, attempt: identity.attempt, started_at_ms: startedAtMs } satisfies TailHeader);
|
|
74
|
+
const ring: Buffer[] = [];
|
|
75
|
+
let ringBytes = 0;
|
|
76
|
+
let dirty = false;
|
|
77
|
+
let closed = false;
|
|
78
|
+
let failed = false;
|
|
79
|
+
let timer: NodeJS.Timeout | undefined;
|
|
80
|
+
let inFlight: Promise<void> = Promise.resolve();
|
|
81
|
+
|
|
82
|
+
const flush = (): Promise<void> => {
|
|
83
|
+
if (!dirty || failed) return inFlight;
|
|
84
|
+
dirty = false;
|
|
85
|
+
const body = Buffer.concat([Buffer.from(`${header}\n`), ...ring]);
|
|
86
|
+
inFlight = inFlight.then(async () => {
|
|
87
|
+
// Write beside, then rename over: a reader never sees a torn file.
|
|
88
|
+
const staging = `${path}.tmp`;
|
|
89
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
90
|
+
const file = await open(staging, 'w', 0o600);
|
|
91
|
+
try { await file.writeFile(body); } finally { await file.close(); }
|
|
92
|
+
await rename(staging, path);
|
|
93
|
+
}).catch((error: unknown) => {
|
|
94
|
+
if (failed) return;
|
|
95
|
+
failed = true;
|
|
96
|
+
warn(`Transcript tail for ${identity.runId}/${identity.stepId} attempt ${identity.attempt} (${stream}) could not be written; the step continues without it: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
});
|
|
98
|
+
return inFlight;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
append(chunk) {
|
|
103
|
+
if (closed || failed || chunk.length === 0) return;
|
|
104
|
+
if (chunk.length >= TAIL_CAPACITY_BYTES) {
|
|
105
|
+
ring.splice(0, ring.length, Buffer.from(chunk.subarray(chunk.length - TAIL_CAPACITY_BYTES)));
|
|
106
|
+
ringBytes = TAIL_CAPACITY_BYTES;
|
|
107
|
+
} else {
|
|
108
|
+
ring.push(Buffer.from(chunk));
|
|
109
|
+
ringBytes += chunk.length;
|
|
110
|
+
while (ringBytes > TAIL_CAPACITY_BYTES) {
|
|
111
|
+
const excess = ringBytes - TAIL_CAPACITY_BYTES;
|
|
112
|
+
const head = ring[0]!;
|
|
113
|
+
if (head.length <= excess) { ring.shift(); ringBytes -= head.length; } else { ring[0] = head.subarray(excess); ringBytes -= excess; }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
dirty = true;
|
|
117
|
+
if (timer === undefined) {
|
|
118
|
+
timer = setTimeout(() => { timer = undefined; void flush(); }, TAIL_FLUSH_INTERVAL_MS);
|
|
119
|
+
timer.unref();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
async close() {
|
|
123
|
+
if (closed) return;
|
|
124
|
+
closed = true;
|
|
125
|
+
if (timer !== undefined) { clearTimeout(timer); timer = undefined; }
|
|
126
|
+
await flush();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A tail file parsed and checked against the attempt it claims to be. */
|
|
132
|
+
export interface ReadTail {
|
|
133
|
+
header: TailHeader;
|
|
134
|
+
/** Bytes of transcript on disk, after the header line. */
|
|
135
|
+
bytes: number;
|
|
136
|
+
text: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read one tail file. `undefined` when absent, unparseable, or written for a
|
|
141
|
+
* different attempt — or for this attempt number but before the journal says
|
|
142
|
+
* the attempt began, which is a file left by an earlier life of this data dir.
|
|
143
|
+
*/
|
|
144
|
+
export async function readTranscriptTail(
|
|
145
|
+
identity: TailIdentity,
|
|
146
|
+
stream: TailStream,
|
|
147
|
+
attemptStartedAtMs: number | null,
|
|
148
|
+
): Promise<ReadTail | undefined> {
|
|
149
|
+
let raw: Buffer;
|
|
150
|
+
try {
|
|
151
|
+
raw = await readFile(transcriptTailPath(identity, stream));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
const newline = raw.indexOf(10);
|
|
157
|
+
if (newline < 0) return undefined;
|
|
158
|
+
let header: unknown;
|
|
159
|
+
try { header = JSON.parse(raw.subarray(0, newline).toString('utf8')); } catch { return undefined; }
|
|
160
|
+
if (header === null || typeof header !== 'object') return undefined;
|
|
161
|
+
const claimed = header as Partial<TailHeader>;
|
|
162
|
+
if (claimed.v !== 1 || claimed.run_id !== identity.runId || claimed.step_id !== identity.stepId
|
|
163
|
+
|| claimed.attempt !== identity.attempt || typeof claimed.started_at_ms !== 'number') return undefined;
|
|
164
|
+
if (attemptStartedAtMs !== null && claimed.started_at_ms < attemptStartedAtMs) return undefined;
|
|
165
|
+
const body = raw.subarray(newline + 1);
|
|
166
|
+
return { header: claimed as TailHeader, bytes: body.length, text: body.toString('utf8') };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Redact the whole tail before splitting it. A direct agent inherits the
|
|
171
|
+
* worker's environment, so it can print a multiline secret; splitting first
|
|
172
|
+
* meant no line held the whole value and `replaceAll` matched none of them.
|
|
173
|
+
*/
|
|
174
|
+
function lastLines(text: string, count: number, env: NodeJS.ProcessEnv): string[] {
|
|
175
|
+
const lines = redact(text, env).split('\n');
|
|
176
|
+
if (lines.at(-1) === '') lines.pop();
|
|
177
|
+
return lines.slice(Math.max(0, lines.length - count));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The {@link TailSource} `flows status` uses: this step's latest attempt, redacted. */
|
|
181
|
+
export function transcriptTailSource(env: NodeJS.ProcessEnv = process.env): TailSource {
|
|
182
|
+
return {
|
|
183
|
+
async read(dataDir: string, runId: string, step: StepView, lines: number): Promise<StepTails> {
|
|
184
|
+
if (step.type !== 'agent' || step.attempt === 0) return null;
|
|
185
|
+
const identity = { dataDir, runId, stepId: step.id, attempt: step.attempt };
|
|
186
|
+
const tail = async (stream: TailStream): Promise<TranscriptTail | null> => {
|
|
187
|
+
const read = await readTranscriptTail(identity, stream, step.started_at_ms);
|
|
188
|
+
return read === undefined ? null : { attempt: step.attempt, bytes: read.bytes, lines: lastLines(read.text, lines, env) };
|
|
189
|
+
};
|
|
190
|
+
return { stdout: await tail('stdout'), stderr: await tail('stderr') };
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
package/src/worker-cli.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve, sep } from 'node:path';
|
|
2
3
|
import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
|
|
3
4
|
import { claudeResultOutcome, decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
|
|
4
5
|
import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js';
|
|
6
|
+
import { openTranscriptWriter, transcriptPath, type TranscriptDigest, type TranscriptFile, type TranscriptWriter } from './agent-transcript.js';
|
|
5
7
|
import { spawn } from 'node:child_process';
|
|
6
8
|
import { StringDecoder } from 'node:string_decoder';
|
|
7
9
|
import { childStop } from './child-stop.js';
|
|
@@ -19,6 +21,9 @@ import {
|
|
|
19
21
|
type WrapperSessionLimits,
|
|
20
22
|
} from './wrapper-session.js';
|
|
21
23
|
import { wrapperEnvironment } from './wrapper-runtime.js';
|
|
24
|
+
import { redactRelayError } from './redact.js';
|
|
25
|
+
import { applyStepEnvironment } from './step-env.js';
|
|
26
|
+
import { TAIL_CLOSE_TIMEOUT_MS, openTranscriptTail, type TranscriptTailWriter } from './transcript-tail.js';
|
|
22
27
|
import {
|
|
23
28
|
runAgentRelayTask,
|
|
24
29
|
AgentRelayTransportError,
|
|
@@ -52,6 +57,15 @@ export interface WorkerCliResult {
|
|
|
52
57
|
* agent runs on another host.
|
|
53
58
|
*/
|
|
54
59
|
artifacts?: string[];
|
|
60
|
+
/**
|
|
61
|
+
* The attempt's transcript digest (`agent-transcript.ts`): provider result
|
|
62
|
+
* metadata, tool calls summarized, final text, failure excerpt, and — when
|
|
63
|
+
* the spawn had a sidechannel context with an attempt — the pointer to the
|
|
64
|
+
* redacted, capped `stream-json` file. Journaled in `trajectory_tail`, never
|
|
65
|
+
* in `output`. Present for Claude/Codex executions; absent for wrappers and
|
|
66
|
+
* the relay transport, which stream no provider frames through this process.
|
|
67
|
+
*/
|
|
68
|
+
transcript?: TranscriptDigest;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
/**
|
|
@@ -95,23 +109,39 @@ export async function runAgentCli(
|
|
|
95
109
|
// workspace to change. Executions sharing a working directory are
|
|
96
110
|
// serialized around their snapshot-spawn-snapshot interval, so one agent's
|
|
97
111
|
// writes are never attributed to a concurrent one in the same directory.
|
|
112
|
+
//
|
|
113
|
+
// Bookkeeping written during that interval is not agent-authored content,
|
|
114
|
+
// and when the data dir sits under the cwd (a local `--data-dir` inside the
|
|
115
|
+
// project) all of it lands inside the scanned tree: this process's own
|
|
116
|
+
// transcript file and two `flows status --tail` tails, and the run journals
|
|
117
|
+
// the daemon appends to while the agent runs. So the whole data dir is
|
|
118
|
+
// dropped from the diff. Anything left in reports the kernel's own state
|
|
119
|
+
// back to the kernel as the step's artifacts, where an `artifact_exists`
|
|
120
|
+
// gate then reads it.
|
|
98
121
|
const artifactRoot = mode === 'agent' ? resolve(cwd ?? process.cwd()) : undefined;
|
|
99
122
|
return artifactRoot === undefined
|
|
100
123
|
? execute()
|
|
101
124
|
: serializedByDirectory(artifactRoot, async () => {
|
|
102
125
|
const before = await snapshotWorkspaceFiles(artifactRoot);
|
|
103
126
|
const result = await execute();
|
|
104
|
-
|
|
127
|
+
const kernelData = sidechannel === undefined ? undefined : realPath(resolve(sidechannel.dataDir));
|
|
128
|
+
const artifacts = diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot))
|
|
129
|
+
.filter(path => !under(realPath(resolve(artifactRoot, path)), kernelData));
|
|
130
|
+
return { ...result, artifacts };
|
|
105
131
|
});
|
|
106
132
|
|
|
107
133
|
async function execute(): Promise<WorkerCliResult> {
|
|
108
134
|
if (kind === 'relayflows-wrapper-v1') {
|
|
135
|
+
// The closed allowlist admits no ambient RELAYFLOW_* value; the four
|
|
136
|
+
// discovery names are set from this dispatch, exactly as for a direct spawn.
|
|
137
|
+
const wrapperEnv = wrapperEnvironment(process.env);
|
|
138
|
+
applyStepEnvironment(wrapperEnv, sidechannel);
|
|
109
139
|
return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
|
|
110
140
|
cli,
|
|
111
141
|
instruction,
|
|
112
142
|
wakeContext,
|
|
113
143
|
effectiveModel,
|
|
114
|
-
|
|
144
|
+
wrapperEnv,
|
|
115
145
|
wrapperLimits,
|
|
116
146
|
signal,
|
|
117
147
|
cwd,
|
|
@@ -121,6 +151,9 @@ export async function runAgentCli(
|
|
|
121
151
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
122
152
|
delete env[WAKE_CONTEXT_ENV];
|
|
123
153
|
delete env[MODEL_ENV];
|
|
154
|
+
// Where this attempt's journal is, so the agent can run `flows status` on
|
|
155
|
+
// itself. Only a worker with a data dir knows; an ad-hoc spawn exports nothing.
|
|
156
|
+
applyStepEnvironment(env, sidechannel);
|
|
124
157
|
const invocation = mode === 'llm' ? llmExecution(kind, instruction, effectiveModel) : agentExecution(kind, instruction, effectiveModel);
|
|
125
158
|
|
|
126
159
|
if (wakeContext !== undefined) {
|
|
@@ -142,10 +175,51 @@ export async function runAgentCli(
|
|
|
142
175
|
const args = [...invocation.args];
|
|
143
176
|
args.splice(args.length - 1, 0, ...(kind === 'claude' ? ['--output-format', 'stream-json', '--verbose'] : ['--json']));
|
|
144
177
|
const completion = kind === 'claude' ? claudeResultOutcome : undefined;
|
|
145
|
-
return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion), kind), effectiveModel);
|
|
178
|
+
return requirePricedUsage(decodeProviderResult(await spawnInvocation(cli, { ...invocation, args }, env, signal, sidechannel, cwd, completion), kind, env), effectiveModel);
|
|
146
179
|
}
|
|
147
180
|
}
|
|
148
181
|
|
|
182
|
+
function openTails(context: SidechannelContext): { stdout: TranscriptTailWriter; stderr: TranscriptTailWriter } | undefined {
|
|
183
|
+
// Same rule as the per-attempt transcript file: no attempt, no tails.
|
|
184
|
+
const attempt = context.attempt;
|
|
185
|
+
if (attempt === undefined) return undefined;
|
|
186
|
+
const identity = { dataDir: context.dataDir, runId: context.runId, stepId: context.stepId, attempt };
|
|
187
|
+
try {
|
|
188
|
+
return { stdout: openTranscriptTail(identity, 'stdout'), stderr: openTranscriptTail(identity, 'stderr') };
|
|
189
|
+
} catch {
|
|
190
|
+
// An id the path cannot carry; the sidechannel declined it the same way.
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether `path` is `directory` or anything beneath it, both symlink-free.
|
|
197
|
+
*
|
|
198
|
+
* The artifact exclusion is a subtree, not a list of names, because the files
|
|
199
|
+
* to exclude are not all knowable in advance. The attempt's own transcript and
|
|
200
|
+
* tails are (they derive from the attempt identity), but the daemon decides on
|
|
201
|
+
* its own when to write which run journal, and a journal appended to inside
|
|
202
|
+
* the snapshot interval is indistinguishable in a diff from a file the agent
|
|
203
|
+
* wrote. Excluding the directory covers both, and covers them without reading
|
|
204
|
+
* the result: the transcript pointer there is optional — `finish` drops it
|
|
205
|
+
* when the close outruns `TAIL_CLOSE_TIMEOUT_MS` and `discardTranscript` drops
|
|
206
|
+
* it on abort, while the file stays on disk — so an exclusion built from it
|
|
207
|
+
* lost the transcript exactly when the close was slow (flows#495).
|
|
208
|
+
*
|
|
209
|
+
* `false` when there is no data dir to exclude, which is exactly when this
|
|
210
|
+
* process has nowhere to write.
|
|
211
|
+
*/
|
|
212
|
+
function under(path: string | undefined, directory: string | undefined): boolean {
|
|
213
|
+
if (path === undefined || directory === undefined) return false;
|
|
214
|
+
return path === directory || path.startsWith(directory.endsWith(sep) ? directory : directory + sep);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Symlink-free form of a path, or the path itself when it cannot be resolved. */
|
|
218
|
+
function realPath(path: string | undefined): string | undefined {
|
|
219
|
+
if (path === undefined) return undefined;
|
|
220
|
+
try { return realpathSync(path); } catch { return path; }
|
|
221
|
+
}
|
|
222
|
+
|
|
149
223
|
/** One agent at a time per canonical working directory, for the artifact interval. */
|
|
150
224
|
const directoryQueues = new Map<string, Promise<unknown>>();
|
|
151
225
|
function serializedByDirectory<T>(directory: string, task: () => Promise<T>): Promise<T> {
|
|
@@ -191,14 +265,6 @@ async function runViaAgentRelay(
|
|
|
191
265
|
}
|
|
192
266
|
}
|
|
193
267
|
|
|
194
|
-
function redactRelayError(message: string): string {
|
|
195
|
-
for (const key of ['RELAY_AGENT_TOKEN', 'RELAY_API_KEY']) {
|
|
196
|
-
const secret = process.env[key];
|
|
197
|
-
if (secret) message = message.replaceAll(secret, '[redacted]');
|
|
198
|
-
}
|
|
199
|
-
return message.replace(/\b(?:at|rk|nt|ot|br|arr)_(?:live_)?[A-Za-z0-9_-]+/g, '[redacted]');
|
|
200
|
-
}
|
|
201
|
-
|
|
202
268
|
/**
|
|
203
269
|
* How long a CLI that has reported its final result may take to exit. Claude
|
|
204
270
|
* Code in print mode waits, after its result, for every background task it
|
|
@@ -220,11 +286,27 @@ async function spawnInvocation(
|
|
|
220
286
|
let writeInput: (bytes: Buffer) => Promise<boolean> = async () => false;
|
|
221
287
|
let canDrive = () => false;
|
|
222
288
|
let driven = false;
|
|
289
|
+
// The transcript file lives beside the PTY socket and is named by attempt.
|
|
290
|
+
// Its absence (no data dir, no attempt, unwritable dir) costs the step
|
|
291
|
+
// nothing: the digest is built from the buffered frames regardless. Opened
|
|
292
|
+
// BEFORE the sidechannel: once `onReady` has fired, a drive peer's HELLO may
|
|
293
|
+
// arrive at any time, and nothing may sit between that and the spawn that
|
|
294
|
+
// arms `canDrive`.
|
|
295
|
+
const writer: TranscriptWriter | undefined = sidechannel?.attempt === undefined ? undefined
|
|
296
|
+
: await openTranscriptWriter(transcriptPath(sidechannel, sidechannel.attempt), env);
|
|
223
297
|
const channel = sidechannel === undefined ? undefined : await openSidechannel({
|
|
224
298
|
...sidechannel,
|
|
225
299
|
onDrive() { driven = true; sidechannel.onDrive(); },
|
|
226
300
|
}, bytes => writeInput(bytes), () => canDrive());
|
|
227
|
-
|
|
301
|
+
// Tee the transcript into bounded tail files beside the socket. Evidence
|
|
302
|
+
// for `flows status --tail`, never the record; a failure here is a warning.
|
|
303
|
+
const tails = sidechannel === undefined ? undefined : openTails(sidechannel);
|
|
304
|
+
if (signal?.aborted) {
|
|
305
|
+
channel?.close();
|
|
306
|
+
void writer?.close();
|
|
307
|
+
void tails?.stdout.close(); void tails?.stderr.close();
|
|
308
|
+
signal.throwIfAborted();
|
|
309
|
+
}
|
|
228
310
|
return new Promise((resolve) => {
|
|
229
311
|
// Always a group of its own off Windows, so every stop — and
|
|
230
312
|
// `reapOnExit` — reaches the whole agent tree, lease-bound or not.
|
|
@@ -257,7 +339,7 @@ async function spawnInvocation(
|
|
|
257
339
|
let graceTimer: NodeJS.Timeout | undefined;
|
|
258
340
|
const decoder = new StringDecoder('utf8');
|
|
259
341
|
let partialLine = '';
|
|
260
|
-
const finish = (result: WorkerCliResult): void => {
|
|
342
|
+
const finish = (result: WorkerCliResult, discardTranscript = false): void => {
|
|
261
343
|
if (settled) return;
|
|
262
344
|
settled = true;
|
|
263
345
|
if (inputTimer !== undefined) clearTimeout(inputTimer);
|
|
@@ -265,11 +347,42 @@ async function spawnInvocation(
|
|
|
265
347
|
if (timer !== undefined) clearTimeout(timer);
|
|
266
348
|
if (graceTimer !== undefined) clearTimeout(graceTimer);
|
|
267
349
|
signal?.removeEventListener('abort', onAbort);
|
|
268
|
-
resolve(result);
|
|
350
|
+
if (writer === undefined && tails === undefined) { resolve(result); return; }
|
|
351
|
+
if (writer !== undefined) {
|
|
352
|
+
const rest = partialLine + decoder.end();
|
|
353
|
+
if (rest.length > 0) writer.write(rest);
|
|
354
|
+
}
|
|
355
|
+
// Two independent pieces of evidence close here: the per-attempt
|
|
356
|
+
// transcript, whose descriptor rides back on the result as the digest's
|
|
357
|
+
// file pointer, and the `flows status --tail` tails, which nothing reads
|
|
358
|
+
// from the result.
|
|
359
|
+
//
|
|
360
|
+
// Neither may hold a step open. A stalled write would leave
|
|
361
|
+
// `runAgentCli` unresolved forever, and every later abort or timeout is
|
|
362
|
+
// already a no-op once `settled` is true — so the whole close is raced
|
|
363
|
+
// against a bounded deadline and the result stands either way. Past the
|
|
364
|
+
// deadline the transcript pointer is dropped rather than waited for: a
|
|
365
|
+
// completion without a pointer is recoverable, a step that never
|
|
366
|
+
// completes is not.
|
|
367
|
+
// The writer's result is recorded the moment it lands, not read out of
|
|
368
|
+
// the combined race: the two closes are independent, and a stalled tail
|
|
369
|
+
// must not throw away a transcript that finished and is on disk.
|
|
370
|
+
let transcriptFile: TranscriptFile | undefined;
|
|
371
|
+
const closedWriter = writer === undefined
|
|
372
|
+
? Promise.resolve()
|
|
373
|
+
: writer.close().then((file) => { transcriptFile = file; }, () => {});
|
|
374
|
+
const closedTails = tails === undefined
|
|
375
|
+
? Promise.resolve()
|
|
376
|
+
: Promise.all([tails.stdout.close(), tails.stderr.close()]).then(() => undefined, () => undefined);
|
|
377
|
+
const deadline = new Promise<void>((done) => setTimeout(done, TAIL_CLOSE_TIMEOUT_MS).unref?.());
|
|
378
|
+
void Promise.race([Promise.all([closedWriter, closedTails]), deadline]).then(() => {
|
|
379
|
+
resolve(discardTranscript || transcriptFile === undefined
|
|
380
|
+
? result : { ...result, transcript: { file: transcriptFile } });
|
|
381
|
+
}, () => resolve(result));
|
|
269
382
|
};
|
|
270
383
|
const onAbort = (): void => {
|
|
271
384
|
stop.kill();
|
|
272
|
-
finish({ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' });
|
|
385
|
+
finish({ exit_code: null, stdout_tail: '', stderr_tail: 'Agent execution aborted: lease ownership lost.' }, true);
|
|
273
386
|
};
|
|
274
387
|
/**
|
|
275
388
|
* Same invariant as `wrapper-session.ts`: `'close'` and `'error'` are
|
|
@@ -306,15 +419,23 @@ async function spawnInvocation(
|
|
|
306
419
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
307
420
|
stdout.push(chunk);
|
|
308
421
|
channel?.publish(chunk);
|
|
309
|
-
|
|
422
|
+
// The tails take raw bytes, so they are fed before any line splitting
|
|
423
|
+
// and regardless of what the writer or the detector still wants.
|
|
424
|
+
tails?.stdout.append(chunk);
|
|
425
|
+
// Lines are split whenever a writer or a completion detector wants
|
|
426
|
+
// them; the detector stops looking once a result has been seen, the
|
|
427
|
+
// writer keeps every line to the end.
|
|
428
|
+
if (writer === undefined && (completion === undefined || graceTimer !== undefined)) return;
|
|
310
429
|
const lines = (partialLine + decoder.write(chunk)).split('\n');
|
|
311
430
|
partialLine = lines.pop() ?? '';
|
|
312
431
|
for (const line of lines) {
|
|
432
|
+
writer?.write(line);
|
|
433
|
+
if (completion === undefined || graceTimer !== undefined) continue;
|
|
313
434
|
const outcome = completion(line);
|
|
314
435
|
if (outcome !== undefined) onResult(outcome);
|
|
315
436
|
}
|
|
316
437
|
});
|
|
317
|
-
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); });
|
|
438
|
+
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk); channel?.publish(chunk); tails?.stderr.append(chunk); });
|
|
318
439
|
child.once('error', (error) => finishOnChildExit({
|
|
319
440
|
exit_code: null,
|
|
320
441
|
stdout_tail: Buffer.concat(stdout).toString('utf8'),
|
package/src/worker-usage.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import type { WorkerCliResult } from './worker-cli.js';
|
|
2
2
|
import { MODEL_PRICING } from './model-pricing.js';
|
|
3
|
+
import { FAILURE_EXCERPT_MAX_BYTES, buildTranscriptDigest, redactText, utf8Tail } from './agent-transcript.js';
|
|
3
4
|
|
|
4
5
|
type RecordValue = Record<string, unknown>;
|
|
5
6
|
const record = (value: unknown): value is RecordValue => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
7
|
const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A completion this decoder refuses. The refusal is appended to stderr, and a
|
|
11
|
+
* digest already built from the frames gets it as its failure — otherwise the
|
|
12
|
+
* digest would still describe the attempt as the provider reported it.
|
|
13
|
+
*/
|
|
8
14
|
function invalid(result: WorkerCliResult, detail: string): WorkerCliResult {
|
|
9
|
-
|
|
15
|
+
const stderr_tail = `${result.stderr_tail}\n${detail}`.trim();
|
|
16
|
+
const transcript = result.transcript === undefined ? undefined : { ...result.transcript,
|
|
17
|
+
failure: { kind: 'stderr' as const, excerpt: utf8Tail(redactText(stderr_tail), FAILURE_EXCERPT_MAX_BYTES) } };
|
|
18
|
+
return { ...result, exit_code: null, stderr_tail, ...(transcript === undefined ? {} : { transcript }) };
|
|
10
19
|
}
|
|
11
20
|
|
|
12
21
|
function usageResult(result: WorkerCliResult, usage: unknown, output: unknown): WorkerCliResult {
|
|
@@ -18,12 +27,26 @@ function usageResult(result: WorkerCliResult, usage: unknown, output: unknown):
|
|
|
18
27
|
tokens_input: usage.input_tokens, tokens_output: usage.output_tokens };
|
|
19
28
|
}
|
|
20
29
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Provider envelopes are execution metadata, never the authored output. The
|
|
32
|
+
* same frames also yield the transcript digest (`agent-transcript.ts`): the
|
|
33
|
+
* metered `tokens_input/output` stay the budget source, and the provider's own
|
|
34
|
+
* cost, model, tool calls and failure ride beside them in `transcript`.
|
|
35
|
+
*/
|
|
36
|
+
export function decodeProviderResult(
|
|
37
|
+
result: WorkerCliResult,
|
|
38
|
+
kind: 'claude' | 'codex',
|
|
39
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
40
|
+
): WorkerCliResult {
|
|
23
41
|
const frames: RecordValue[] = [];
|
|
24
42
|
for (const line of result.stdout_tail.split('\n')) {
|
|
25
43
|
try { const frame: unknown = JSON.parse(line); if (record(frame)) frames.push(frame); } catch { /* Plain text remains output. */ }
|
|
26
44
|
}
|
|
45
|
+
const decoded = decodeUsage(result, frames, kind);
|
|
46
|
+
return { ...decoded, transcript: { ...result.transcript, ...buildTranscriptDigest(frames, kind, decoded, env) } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodeUsage(result: WorkerCliResult, frames: RecordValue[], kind: 'claude' | 'codex'): WorkerCliResult {
|
|
27
50
|
const terminal = [...frames].reverse().find(f => kind === 'claude' ? f.type === 'result' : f.type === 'turn.completed');
|
|
28
51
|
if (terminal === undefined) return result;
|
|
29
52
|
const text = kind === 'claude' ? terminal.result : frames
|
package/src/worker.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { communicationInstruction } from './communication/spec.js';
|
|
1
2
|
import { workerSpend } from './worker-spend.js';
|
|
2
3
|
import type { WorkerCliResult } from './worker-cli.js';
|
|
3
4
|
import { EventEmitter } from 'node:events';
|
|
@@ -10,6 +11,7 @@ import { withWorkerLease } from './worker-lease.js';
|
|
|
10
11
|
import { workerInstruction } from './worker-input.js';
|
|
11
12
|
import { helperCall } from './yaml-helpers.js';
|
|
12
13
|
import { completeHelperDispatch } from './yaml-helper-effect.js';
|
|
14
|
+
import { ARTIFACT_PATHS_MAX, boundTranscriptDigest, type TranscriptDigest } from './agent-transcript.js';
|
|
13
15
|
|
|
14
16
|
export { MODEL_ENV, WAKE_CONTEXT_ENV } from './worker-cli.js';
|
|
15
17
|
|
|
@@ -17,6 +19,7 @@ export interface AgentWorkerOptions {
|
|
|
17
19
|
workerId: string;
|
|
18
20
|
pins: Pins;
|
|
19
21
|
capacity?: number;
|
|
22
|
+
requiredStreams?: string[];
|
|
20
23
|
dataDir?: string;
|
|
21
24
|
onPtyReady?: (path: string) => void;
|
|
22
25
|
}
|
|
@@ -59,6 +62,7 @@ export class AgentWorker extends EventEmitter {
|
|
|
59
62
|
['agent'],
|
|
60
63
|
this.options.pins,
|
|
61
64
|
this.options.capacity,
|
|
65
|
+
this.options.requiredStreams,
|
|
62
66
|
);
|
|
63
67
|
this.attached = true;
|
|
64
68
|
} catch (error) {
|
|
@@ -105,12 +109,19 @@ export class AgentWorker extends EventEmitter {
|
|
|
105
109
|
await completeHelperDispatch(this.client, dispatch, helper, this.options.dataDir);
|
|
106
110
|
return;
|
|
107
111
|
}
|
|
112
|
+
const communication = communicationInstruction(spec.instruction);
|
|
113
|
+
if (communication) {
|
|
114
|
+
if (!this.options.dataDir) throw new Error('Agent communication requires a worker data directory');
|
|
115
|
+
const { completeCommunicationDispatch } = await import('./communication/worker.js');
|
|
116
|
+
await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
108
119
|
let humanIntervention = false;
|
|
109
120
|
const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
|
|
110
121
|
const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
|
|
111
122
|
typeof spec.cli === 'string' && typeof spec.instruction === 'string'
|
|
112
123
|
? runAgentCli(spec.cli, workerInstruction(spec.instruction, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'agent', this.options.dataDir === undefined ? undefined : {
|
|
113
|
-
dataDir: this.options.dataDir, runId: dispatch.run_id, stepId: dispatch.step_id,
|
|
124
|
+
dataDir: this.options.dataDir, runId: dispatch.run_id, stepId: dispatch.step_id, attempt: dispatch.attempt,
|
|
114
125
|
onReady: this.options.onPtyReady, onDrive: () => { humanIntervention = true; },
|
|
115
126
|
}, typeof spec.cwd === 'string' ? spec.cwd : undefined,
|
|
116
127
|
spec.transport === 'relay' ? 'relay' : 'direct',
|
|
@@ -142,8 +153,20 @@ export class AgentWorker extends EventEmitter {
|
|
|
142
153
|
// or `summary` never asked for. An agent that answers with a JSON object
|
|
143
154
|
// therefore journals no artifacts; gate such a step on a deterministic
|
|
144
155
|
// check instead. The relay transport reports none (the agent ran elsewhere).
|
|
156
|
+
//
|
|
157
|
+
// `transcript` is not part of the wrapper either: it is evidence about the
|
|
158
|
+
// attempt, journaled in `trajectory_tail` below on success and failure
|
|
159
|
+
// alike, where the kernel already accepts and bounds it (16 KiB).
|
|
160
|
+
const { transcript, ...wrapper } = result;
|
|
145
161
|
const output = result.relay_task?.status === 'completed' && result.exit_code === 0
|
|
146
|
-
? result.relay_task.output : parseJsonOutput(result.stdout_tail) ??
|
|
162
|
+
? result.relay_task.output : parseJsonOutput(result.stdout_tail) ?? wrapper;
|
|
163
|
+
const trajectoryTail = {
|
|
164
|
+
...(result.relay_task === undefined ? {} : { relay_task: {
|
|
165
|
+
invocation_id: result.relay_task.invocation_id, status: result.relay_task.status,
|
|
166
|
+
task_execution: result.relay_task.task_execution, error: result.relay_task.error,
|
|
167
|
+
} }),
|
|
168
|
+
...(transcript === undefined ? {} : { transcript: transcriptDigest(transcript, dispatch.attempt, result) }),
|
|
169
|
+
};
|
|
147
170
|
|
|
148
171
|
await this.client.stepComplete(
|
|
149
172
|
dispatch.run_id,
|
|
@@ -153,10 +176,7 @@ export class AgentWorker extends EventEmitter {
|
|
|
153
176
|
completionReason,
|
|
154
177
|
{
|
|
155
178
|
output,
|
|
156
|
-
...(
|
|
157
|
-
invocation_id: result.relay_task.invocation_id, status: result.relay_task.status,
|
|
158
|
-
task_execution: result.relay_task.task_execution, error: result.relay_task.error,
|
|
159
|
-
} } }),
|
|
179
|
+
...(Object.keys(trajectoryTail).length === 0 ? {} : { trajectory_tail: trajectoryTail }),
|
|
160
180
|
...(humanIntervention ? { human_intervention: true } : {}),
|
|
161
181
|
...(usage !== undefined ? { usage } : {}),
|
|
162
182
|
started_pins: dispatch.pins,
|
|
@@ -166,6 +186,22 @@ export class AgentWorker extends EventEmitter {
|
|
|
166
186
|
}
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The digest as journaled: the attempt and exit code (dropped from `output` on
|
|
191
|
+
* the JSON path by design) and the artifact paths folded in, then bounded to
|
|
192
|
+
* `TRANSCRIPT_DIGEST_MAX_BYTES` with every cut stated on the digest itself.
|
|
193
|
+
*/
|
|
194
|
+
function transcriptDigest(transcript: TranscriptDigest, attempt: number, result: WorkerCliResult): TranscriptDigest {
|
|
195
|
+
return boundTranscriptDigest({
|
|
196
|
+
attempt,
|
|
197
|
+
exit_code: result.exit_code,
|
|
198
|
+
...transcript,
|
|
199
|
+
...(result.artifacts === undefined ? {} : { artifacts: {
|
|
200
|
+
count: result.artifacts.length, paths: result.artifacts.slice(0, ARTIFACT_PATHS_MAX),
|
|
201
|
+
} }),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
/**
|
|
170
206
|
* Return an object-shaped JSON payload parsed from `stdout`, or
|
|
171
207
|
* `null` when stdout is empty, non-JSON, or JSON-of-a-scalar/array.
|
package/src/wrapper-runtime.ts
CHANGED
|
@@ -25,7 +25,13 @@ export interface WrapperIdentity {
|
|
|
25
25
|
fingerprint: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Build a wrapper environment from a closed list; ambient credentials never
|
|
30
|
+
* cross. The step discovery names (`RELAYFLOW_DATA_DIR`, `RELAYFLOW_RUN_ID`,
|
|
31
|
+
* `RELAYFLOW_STEP_ID`, `RELAYFLOW_ATTEMPT`) are deliberately not on the list:
|
|
32
|
+
* an ambient value is a parent step's identity, not this one's, so the worker
|
|
33
|
+
* sets them from the dispatch after this filter (step-env.ts).
|
|
34
|
+
*/
|
|
29
35
|
export function wrapperEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
30
36
|
const env: NodeJS.ProcessEnv = {};
|
|
31
37
|
for (const name of WRAPPER_ENV_ALLOWLIST) {
|