doer-agent 0.9.10 → 0.9.12
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-codex-app-rpc.js +21 -14
- package/dist/codex-text-task.js +19 -1
- package/dist/codex-thread-handoff-jobs.js +14 -5
- package/dist/codex-thread-handoff-jobs.test.js +11 -0
- package/dist/codex-thread-handoff.js +63 -8
- package/dist/codex-thread-handoff.test.js +19 -1
- package/package.json +1 -1
|
@@ -190,20 +190,27 @@ async function handleCodexAppRpcMessage(args) {
|
|
|
190
190
|
}
|
|
191
191
|
export function subscribeToCodexAppRpc(args) {
|
|
192
192
|
const handoffJobs = new CodexThreadHandoffJobManager(async (input, onProgress) => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
193
|
+
return await createCodexThreadHandoff({
|
|
194
|
+
manager: args.manager,
|
|
195
|
+
...input,
|
|
196
|
+
onProgress,
|
|
197
|
+
onLog: args.onInfo,
|
|
198
|
+
});
|
|
199
|
+
}, {
|
|
200
|
+
onStatus: (job) => {
|
|
201
|
+
const elapsedMs = Math.max(0, Date.parse(job.updatedAt) - Date.parse(job.createdAt));
|
|
202
|
+
const target = job.result?.threadId ? ` targetThreadId=${job.result.threadId}` : "";
|
|
203
|
+
const error = job.error
|
|
204
|
+
? ` error=${JSON.stringify(job.error.replace(/\s+/g, " ").slice(0, 500))}`
|
|
205
|
+
: "";
|
|
206
|
+
const message = `thread handoff job status jobId=${job.jobId} sourceThreadId=${job.sourceThreadId} phase=${job.phase} elapsedMs=${elapsedMs}${target}${error}`;
|
|
207
|
+
if (job.phase === "failed") {
|
|
208
|
+
args.onError(message);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
args.onInfo(message);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
207
214
|
});
|
|
208
215
|
args.nc.closed().finally(() => {
|
|
209
216
|
void args.manager.stop().catch(() => undefined);
|
package/dist/codex-text-task.js
CHANGED
|
@@ -37,14 +37,17 @@ function buildCodexUserInput(prompt) {
|
|
|
37
37
|
}];
|
|
38
38
|
}
|
|
39
39
|
export async function runCodexTextTask(args) {
|
|
40
|
+
const startedAt = Date.now();
|
|
40
41
|
let threadId = "";
|
|
41
42
|
let turnId = "";
|
|
42
43
|
let output = "";
|
|
44
|
+
let outputStarted = false;
|
|
43
45
|
let settled = false;
|
|
44
46
|
let cleanupNotification = () => { };
|
|
45
47
|
let settle = (_callback) => { };
|
|
46
48
|
const completed = new Promise((resolve, reject) => {
|
|
47
49
|
const timeout = setTimeout(() => {
|
|
50
|
+
args.onLog?.(`codex text task status phase=timed_out threadId=${threadId || "pending"} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt} outputChars=${output.length}`);
|
|
48
51
|
settle(() => reject(new Error("Timed out while waiting for Codex text task")));
|
|
49
52
|
}, args.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
50
53
|
settle = (callback) => {
|
|
@@ -69,11 +72,16 @@ export async function runCodexTextTask(args) {
|
|
|
69
72
|
if (method === "item/agentMessage/delta") {
|
|
70
73
|
const record = recordValue(params);
|
|
71
74
|
output += stringValue(record?.delta) || stringValue(record?.text);
|
|
75
|
+
if (!outputStarted && output) {
|
|
76
|
+
outputStarted = true;
|
|
77
|
+
args.onLog?.(`codex text task status phase=responding threadId=${threadId} turnId=${turnId || eventTurnId || "pending"} elapsedMs=${Date.now() - startedAt}`);
|
|
78
|
+
}
|
|
72
79
|
return;
|
|
73
80
|
}
|
|
74
81
|
if (!isTerminalTurnMethod(method)) {
|
|
75
82
|
return;
|
|
76
83
|
}
|
|
84
|
+
args.onLog?.(`codex text task status phase=terminal_notification method=${method} threadId=${threadId} turnId=${turnId || eventTurnId || "pending"} elapsedMs=${Date.now() - startedAt} outputChars=${output.length}`);
|
|
77
85
|
if (method === "turn/completed") {
|
|
78
86
|
settle(resolve);
|
|
79
87
|
return;
|
|
@@ -92,6 +100,7 @@ export async function runCodexTextTask(args) {
|
|
|
92
100
|
if (!threadId) {
|
|
93
101
|
throw new Error("Codex app-server did not return a text task thread id");
|
|
94
102
|
}
|
|
103
|
+
args.onLog?.(`codex text task status phase=thread_started threadId=${threadId} elapsedMs=${Date.now() - startedAt}`);
|
|
95
104
|
const turnResult = recordValue(await args.manager.request("turn/start", {
|
|
96
105
|
threadId,
|
|
97
106
|
input: buildCodexUserInput(args.prompt),
|
|
@@ -100,16 +109,25 @@ export async function runCodexTextTask(args) {
|
|
|
100
109
|
if (!turnId) {
|
|
101
110
|
throw new Error("Codex app-server did not return a text task turn id");
|
|
102
111
|
}
|
|
112
|
+
args.onLog?.(`codex text task status phase=turn_started threadId=${threadId} turnId=${turnId} elapsedMs=${Date.now() - startedAt}`);
|
|
103
113
|
await completed.finally(() => cleanupNotification());
|
|
114
|
+
args.onLog?.(`codex text task status phase=completed threadId=${threadId} turnId=${turnId} elapsedMs=${Date.now() - startedAt} outputChars=${output.length}`);
|
|
104
115
|
return output;
|
|
105
116
|
}
|
|
106
117
|
catch (error) {
|
|
107
118
|
settle(() => { });
|
|
119
|
+
args.onLog?.(`codex text task status phase=failed threadId=${threadId || "pending"} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt} error=${JSON.stringify((error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").slice(0, 500))}`);
|
|
108
120
|
throw error;
|
|
109
121
|
}
|
|
110
122
|
finally {
|
|
111
123
|
if (threadId) {
|
|
112
|
-
await args.manager.request("thread/unsubscribe", { threadId }, 30_000)
|
|
124
|
+
await args.manager.request("thread/unsubscribe", { threadId }, 30_000)
|
|
125
|
+
.then(() => {
|
|
126
|
+
args.onLog?.(`codex text task status phase=unsubscribed threadId=${threadId} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt}`);
|
|
127
|
+
})
|
|
128
|
+
.catch((error) => {
|
|
129
|
+
args.onLog?.(`codex text task status phase=unsubscribe_failed threadId=${threadId} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt} error=${JSON.stringify((error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").slice(0, 500))}`);
|
|
130
|
+
});
|
|
113
131
|
}
|
|
114
132
|
}
|
|
115
133
|
}
|
|
@@ -8,14 +8,16 @@ function copySnapshot(job) {
|
|
|
8
8
|
}
|
|
9
9
|
export class CodexThreadHandoffJobManager {
|
|
10
10
|
run;
|
|
11
|
-
terminalTtlMs;
|
|
12
|
-
maxJobs;
|
|
13
11
|
jobs = new Map();
|
|
14
12
|
activeJobBySourceThreadId = new Map();
|
|
15
|
-
|
|
13
|
+
terminalTtlMs;
|
|
14
|
+
maxJobs;
|
|
15
|
+
onStatus;
|
|
16
|
+
constructor(run, options = {}) {
|
|
16
17
|
this.run = run;
|
|
17
|
-
this.terminalTtlMs = terminalTtlMs;
|
|
18
|
-
this.maxJobs = maxJobs;
|
|
18
|
+
this.terminalTtlMs = options.terminalTtlMs ?? 30 * 60_000;
|
|
19
|
+
this.maxJobs = options.maxJobs ?? 100;
|
|
20
|
+
this.onStatus = options.onStatus;
|
|
19
21
|
}
|
|
20
22
|
start(input) {
|
|
21
23
|
this.cleanup();
|
|
@@ -37,6 +39,7 @@ export class CodexThreadHandoffJobManager {
|
|
|
37
39
|
};
|
|
38
40
|
this.jobs.set(job.jobId, job);
|
|
39
41
|
this.activeJobBySourceThreadId.set(input.sourceThreadId, job.jobId);
|
|
42
|
+
this.emitStatus(job);
|
|
40
43
|
setImmediate(() => {
|
|
41
44
|
void this.execute(job.jobId, input);
|
|
42
45
|
});
|
|
@@ -55,6 +58,7 @@ export class CodexThreadHandoffJobManager {
|
|
|
55
58
|
}
|
|
56
59
|
current.phase = phase;
|
|
57
60
|
current.updatedAt = new Date().toISOString();
|
|
61
|
+
this.emitStatus(current);
|
|
58
62
|
};
|
|
59
63
|
try {
|
|
60
64
|
const result = await this.run(input, updatePhase);
|
|
@@ -63,6 +67,7 @@ export class CodexThreadHandoffJobManager {
|
|
|
63
67
|
current.phase = "completed";
|
|
64
68
|
current.result = result;
|
|
65
69
|
current.updatedAt = new Date().toISOString();
|
|
70
|
+
this.emitStatus(current);
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
catch (error) {
|
|
@@ -71,6 +76,7 @@ export class CodexThreadHandoffJobManager {
|
|
|
71
76
|
current.phase = "failed";
|
|
72
77
|
current.error = error instanceof Error ? error.message : String(error);
|
|
73
78
|
current.updatedAt = new Date().toISOString();
|
|
79
|
+
this.emitStatus(current);
|
|
74
80
|
}
|
|
75
81
|
}
|
|
76
82
|
finally {
|
|
@@ -79,6 +85,9 @@ export class CodexThreadHandoffJobManager {
|
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
}
|
|
88
|
+
emitStatus(job) {
|
|
89
|
+
this.onStatus?.(copySnapshot(job));
|
|
90
|
+
}
|
|
82
91
|
cleanup() {
|
|
83
92
|
const cutoff = Date.now() - this.terminalTtlMs;
|
|
84
93
|
for (const [jobId, job] of this.jobs) {
|
|
@@ -17,11 +17,14 @@ async function nextTick() {
|
|
|
17
17
|
}
|
|
18
18
|
test("starts handoff work asynchronously and reports progress", async () => {
|
|
19
19
|
let finish;
|
|
20
|
+
const statuses = [];
|
|
20
21
|
const manager = new CodexThreadHandoffJobManager(async (input, onProgress) => {
|
|
21
22
|
onProgress("collecting");
|
|
22
23
|
return await new Promise((resolve) => {
|
|
23
24
|
finish = resolve;
|
|
24
25
|
});
|
|
26
|
+
}, {
|
|
27
|
+
onStatus: (job) => statuses.push(job.phase),
|
|
25
28
|
});
|
|
26
29
|
const started = manager.start({ sourceThreadId: "source-thread" });
|
|
27
30
|
assert.equal(started.job.phase, "queued");
|
|
@@ -33,6 +36,7 @@ test("starts handoff work asynchronously and reports progress", async () => {
|
|
|
33
36
|
const completed = manager.get(started.job.jobId);
|
|
34
37
|
assert.equal(completed?.phase, "completed");
|
|
35
38
|
assert.equal(completed?.result?.threadId, "target-thread");
|
|
39
|
+
assert.deepEqual(statuses, ["queued", "collecting", "completed"]);
|
|
36
40
|
});
|
|
37
41
|
test("deduplicates active jobs for the same source thread", () => {
|
|
38
42
|
const manager = new CodexThreadHandoffJobManager(async () => {
|
|
@@ -44,8 +48,11 @@ test("deduplicates active jobs for the same source thread", () => {
|
|
|
44
48
|
assert.equal(second.job.jobId, first.job.jobId);
|
|
45
49
|
});
|
|
46
50
|
test("keeps a failed job status for polling", async () => {
|
|
51
|
+
const statuses = [];
|
|
47
52
|
const manager = new CodexThreadHandoffJobManager(async () => {
|
|
48
53
|
throw new Error("summary failed");
|
|
54
|
+
}, {
|
|
55
|
+
onStatus: (job) => statuses.push({ phase: job.phase, error: job.error }),
|
|
49
56
|
});
|
|
50
57
|
const started = manager.start({ sourceThreadId: "source-thread" });
|
|
51
58
|
await nextTick();
|
|
@@ -53,4 +60,8 @@ test("keeps a failed job status for polling", async () => {
|
|
|
53
60
|
const failed = manager.get(started.job.jobId);
|
|
54
61
|
assert.equal(failed?.phase, "failed");
|
|
55
62
|
assert.equal(failed?.error, "summary failed");
|
|
63
|
+
assert.deepEqual(statuses, [
|
|
64
|
+
{ phase: "queued", error: undefined },
|
|
65
|
+
{ phase: "failed", error: "summary failed" },
|
|
66
|
+
]);
|
|
56
67
|
});
|
|
@@ -6,6 +6,50 @@ const MAX_ITEM_TEXT_CHARS = 4_000;
|
|
|
6
6
|
const MAX_TURN_TEXT_CHARS = 8_000;
|
|
7
7
|
const MAX_TRANSCRIPT_CHARS = 160_000;
|
|
8
8
|
const MAX_HANDOFF_CHARS = 20_000;
|
|
9
|
+
const HANDOFF_USER_MARKER = "Continue from the handoff summary above and wait for my next request.";
|
|
10
|
+
export function buildThreadHandoffInjectionItems(handoff) {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
type: "message",
|
|
14
|
+
role: "assistant",
|
|
15
|
+
content: [{
|
|
16
|
+
type: "output_text",
|
|
17
|
+
text: handoff,
|
|
18
|
+
}],
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
export function buildThreadHandoffSeedInput() {
|
|
23
|
+
return [{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: HANDOFF_USER_MARKER,
|
|
26
|
+
}];
|
|
27
|
+
}
|
|
28
|
+
async function waitForThreadTurnStarted(manager, threadId, timeoutMs = 1_000) {
|
|
29
|
+
await new Promise((resolve) => {
|
|
30
|
+
let settled = false;
|
|
31
|
+
let cleanup = () => { };
|
|
32
|
+
const finish = () => {
|
|
33
|
+
if (settled) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
settled = true;
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
cleanup();
|
|
39
|
+
resolve();
|
|
40
|
+
};
|
|
41
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
42
|
+
cleanup = manager.onNotification((method, params) => {
|
|
43
|
+
if (method !== "turn/started") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const event = recordValue(params);
|
|
47
|
+
if (stringValue(event?.threadId) === threadId) {
|
|
48
|
+
finish();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
9
53
|
function recordValue(value) {
|
|
10
54
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
11
55
|
? value
|
|
@@ -319,6 +363,7 @@ export async function createCodexThreadHandoff(args) {
|
|
|
319
363
|
turnsCollected: collected.turnCount,
|
|
320
364
|
turnsOmitted,
|
|
321
365
|
}),
|
|
366
|
+
onLog: args.onLog,
|
|
322
367
|
});
|
|
323
368
|
const handoff = boundedText(handoffRaw, MAX_HANDOFF_CHARS);
|
|
324
369
|
if (!handoff) {
|
|
@@ -338,16 +383,26 @@ export async function createCodexThreadHandoff(args) {
|
|
|
338
383
|
}
|
|
339
384
|
try {
|
|
340
385
|
args.onProgress?.("injecting");
|
|
386
|
+
// Raw injected items do not make a new thread discoverable through thread/list.
|
|
387
|
+
const seedStarted = waitForThreadTurnStarted(args.manager, threadId);
|
|
388
|
+
const seedResult = recordValue(await args.manager.request("turn/start", {
|
|
389
|
+
threadId,
|
|
390
|
+
input: buildThreadHandoffSeedInput(),
|
|
391
|
+
}, 30_000));
|
|
392
|
+
const seedTurnId = stringValue(recordValue(seedResult?.turn)?.id);
|
|
393
|
+
if (!seedTurnId) {
|
|
394
|
+
throw new Error("Codex app-server did not return a handoff seed turn");
|
|
395
|
+
}
|
|
396
|
+
await seedStarted;
|
|
397
|
+
await args.manager.request("turn/interrupt", {
|
|
398
|
+
threadId,
|
|
399
|
+
turnId: seedTurnId,
|
|
400
|
+
}, 30_000).catch((error) => {
|
|
401
|
+
warnings.push(`Could not interrupt handoff seed turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
402
|
+
});
|
|
341
403
|
await args.manager.request("thread/inject_items", {
|
|
342
404
|
threadId,
|
|
343
|
-
items:
|
|
344
|
-
type: "message",
|
|
345
|
-
role: "assistant",
|
|
346
|
-
content: [{
|
|
347
|
-
type: "output_text",
|
|
348
|
-
text: handoff,
|
|
349
|
-
}],
|
|
350
|
-
}],
|
|
405
|
+
items: buildThreadHandoffInjectionItems(handoff),
|
|
351
406
|
}, 90_000);
|
|
352
407
|
}
|
|
353
408
|
catch (error) {
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { buildBoundedHandoffTranscript, summarizeThreadTurn, } from "./codex-thread-handoff.js";
|
|
3
|
+
import { buildBoundedHandoffTranscript, buildThreadHandoffInjectionItems, buildThreadHandoffSeedInput, summarizeThreadTurn, } from "./codex-thread-handoff.js";
|
|
4
|
+
test("buildThreadHandoffInjectionItems adds the summary to model-visible history", () => {
|
|
5
|
+
assert.deepEqual(buildThreadHandoffInjectionItems("# Thread handoff\n\nContinue the work."), [
|
|
6
|
+
{
|
|
7
|
+
type: "message",
|
|
8
|
+
role: "assistant",
|
|
9
|
+
content: [{
|
|
10
|
+
type: "output_text",
|
|
11
|
+
text: "# Thread handoff\n\nContinue the work.",
|
|
12
|
+
}],
|
|
13
|
+
},
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
test("buildThreadHandoffSeedInput creates a real user turn for thread indexing", () => {
|
|
17
|
+
assert.deepEqual(buildThreadHandoffSeedInput(), [{
|
|
18
|
+
type: "text",
|
|
19
|
+
text: "Continue from the handoff summary above and wait for my next request.",
|
|
20
|
+
}]);
|
|
21
|
+
});
|
|
4
22
|
test("summarizeThreadTurn omits raw command output, diffs, images, and MCP results", () => {
|
|
5
23
|
const summary = summarizeThreadTurn({
|
|
6
24
|
id: "turn-1",
|