@stigmer/runner 3.32.0 → 3.33.0
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/.build-fingerprint +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +13 -6
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +21 -5
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/deepagents-profiles.d.ts +21 -7
- package/dist/activities/execute-deep-agent/deepagents-profiles.js +24 -9
- package/dist/activities/execute-deep-agent/deepagents-profiles.js.map +1 -1
- package/dist/activities/execute-deep-agent/platform-route.d.ts +75 -0
- package/dist/activities/execute-deep-agent/platform-route.js +107 -0
- package/dist/activities/execute-deep-agent/platform-route.js.map +1 -0
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +2 -0
- package/dist/activities/execute-deep-agent/subagent-transformer.js +29 -5
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -7
- package/dist/activities/execute-deep-agent/subagent-wiring.js +5 -7
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/execute-deep-agent/translator.d.ts +6 -2
- package/dist/activities/execute-deep-agent/translator.js +6 -2
- package/dist/activities/execute-deep-agent/translator.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn-setup.js +14 -3
- package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
- package/dist/middleware/index.d.ts +7 -3
- package/dist/middleware/index.js +7 -3
- package/dist/middleware/index.js.map +1 -1
- package/dist/shared/workspace/stigmer-link.d.ts +15 -3
- package/dist/shared/workspace/stigmer-link.js +22 -3
- package/dist/shared/workspace/stigmer-link.js.map +1 -1
- package/dist/shared/workspace/types.d.ts +4 -3
- package/package.json +15 -9
- package/src/activities/execute-deep-agent/__tests__/builtin-tool-surface.test.ts +107 -0
- package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +14 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/fixtures/paused-session.deepagents-1.10.2.json +592 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/sub-agent-delegation.status.json +2 -2
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/tool-call.status.json +1 -1
- package/src/activities/execute-deep-agent/__tests__/hermetic/plain-turn.test.ts +10 -13
- package/src/activities/execute-deep-agent/__tests__/hermetic/resume-across-engine-upgrade.test.ts +374 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/sub-agent-delegation.test.ts +7 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/tool-call.test.ts +6 -0
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +16 -8
- package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +148 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
- package/src/activities/execute-deep-agent/__tests__/summarization-verification.test.ts +16 -9
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +25 -6
- package/src/activities/execute-deep-agent/deepagents-profiles.ts +25 -9
- package/src/activities/execute-deep-agent/platform-route.ts +134 -0
- package/src/activities/execute-deep-agent/subagent-transformer.ts +40 -6
- package/src/activities/execute-deep-agent/subagent-wiring.ts +5 -7
- package/src/activities/execute-deep-agent/translator.ts +6 -2
- package/src/activities/execute-deep-agent/turn-setup.ts +17 -3
- package/src/middleware/index.ts +7 -3
- package/src/shared/workspace/__tests__/stigmer-link.test.ts +41 -18
- package/src/shared/workspace/stigmer-link.ts +22 -3
- package/src/shared/workspace/types.ts +4 -3
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"description": "Look up the fixture value.",
|
|
13
13
|
"subagent_type": "helper"
|
|
14
14
|
},
|
|
15
|
-
"result": "
|
|
15
|
+
"result": "The fixture value is forty-two.",
|
|
16
16
|
"status": "TOOL_CALL_COMPLETED",
|
|
17
17
|
"startedAt": "2026-01-01T00:00:01.000Z",
|
|
18
18
|
"completedAt": "2026-01-01T00:00:02.000Z",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"id": "call-hermetic-task-0001",
|
|
34
34
|
"name": "helper",
|
|
35
35
|
"input": "Look up the fixture value.",
|
|
36
|
-
"output": "
|
|
36
|
+
"output": "The fixture value is forty-two.",
|
|
37
37
|
"status": "SUB_AGENT_COMPLETED",
|
|
38
38
|
"startedAt": "2026-01-01T00:00:01.000Z",
|
|
39
39
|
"completedAt": "2026-01-01T00:00:02.000Z",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"args": {
|
|
12
12
|
"file_path": "/README.md"
|
|
13
13
|
},
|
|
14
|
-
"result": "[{\"type\":\"text\",\"text\":\"
|
|
14
|
+
"result": "[{\"type\":\"text\",\"text\":\"@@ lines 1-3 of 3 @@\\n# Hermetic\\n\\nA fixture readme.\"}]",
|
|
15
15
|
"status": "TOOL_CALL_COMPLETED",
|
|
16
16
|
"startedAt": "2026-01-01T00:00:01.000Z",
|
|
17
17
|
"completedAt": "2026-01-01T00:00:01.000Z",
|
|
@@ -15,12 +15,16 @@
|
|
|
15
15
|
* model streams, so the events under this golden are the ones a provider
|
|
16
16
|
* produces (`scripted-model.ts` header).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* The legacy positional `(executionId, threadId)` form the control planes
|
|
19
|
+
* still may send (`shared/activity-input.ts`) rides on the same golden, and
|
|
20
|
+
* matching the SAME file is the assertion that it is invisible to the status.
|
|
21
|
+
* An empty `thread_id` rode here too (carried from `index.test.ts`: "handles
|
|
22
|
+
* empty threadId gracefully") until @langchain/langgraph-checkpoint 1.1's
|
|
23
|
+
* memory saver started refusing an empty key. That arm was only ever true of
|
|
24
|
+
* the test-only memory saver: the native thread id is runtime-minted
|
|
25
|
+
* (`EnsureThread` sends `thread-{sessionId}` on every invocation), and the
|
|
26
|
+
* sqlite saver the runner ships has always refused an empty one
|
|
27
|
+
* (`shared/checkpointer/sqlite-saver.ts`), so no saver left to prove it on.
|
|
24
28
|
*
|
|
25
29
|
* Since #1096 the activity is the turn runtime over the native adapter, and
|
|
26
30
|
* `streamingUsage` carries the runtime accountant's fields — `model`,
|
|
@@ -132,11 +136,4 @@ describe("ExecuteDeepAgent hermetic — plain turn", () => {
|
|
|
132
136
|
const json = JSON.stringify(toJson(AgentExecutionStatusSchema, record.lastFullStatus!), null, 2) + "\n";
|
|
133
137
|
await expect(json).toMatchFileSnapshot(GOLDEN);
|
|
134
138
|
});
|
|
135
|
-
|
|
136
|
-
it("produces the same status with an empty thread_id", async () => {
|
|
137
|
-
const { record, invocation } = await runPlainTurn({ threadId: "" });
|
|
138
|
-
expect(invocation.outcome.kind).toBe("returned");
|
|
139
|
-
const json = JSON.stringify(toJson(AgentExecutionStatusSchema, record.lastFullStatus!), null, 2) + "\n";
|
|
140
|
-
await expect(json).toMatchFileSnapshot(GOLDEN);
|
|
141
|
-
});
|
|
142
139
|
});
|
package/src/activities/execute-deep-agent/__tests__/hermetic/resume-across-engine-upgrade.test.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermetic net: a session paused for approval on an OLDER engine resumes on
|
|
3
|
+
* this one. Every stored checkpoint crosses an engine upgrade (deepagents,
|
|
4
|
+
* langchain, `@langchain/langgraph` and its checkpoint library move
|
|
5
|
+
* together), and cloud sessions live in the server's database, so "a session
|
|
6
|
+
* paused before the upgrade resumes after it" is a property the runner owes on
|
|
7
|
+
* both savers, not an accident of the serializer.
|
|
8
|
+
*
|
|
9
|
+
* The fixtures in `./fixtures/paused-session.deepagents-<version>.json` are
|
|
10
|
+
* the checkpoint rows an OLDER engine wrote, never regenerated after the fact:
|
|
11
|
+
* each is recorded on the engine it names, before the upgrade that leaves it
|
|
12
|
+
* behind, and committed with that upgrade. The scenario it froze:
|
|
13
|
+
*
|
|
14
|
+
* - round 0: the model writes its to-do list (`write_todos`, ungated);
|
|
15
|
+
* - round 1: the model proposes a gated `execute`; the gate's `interrupt()`
|
|
16
|
+
* pauses the graph and the activity persists WAITING_FOR_APPROVAL.
|
|
17
|
+
*
|
|
18
|
+
* A fixture carries the sqlite rows (`checkpoints`, `writes`) with each blob
|
|
19
|
+
* as UTF-8 text when it is JSON (the serializer's `json` tag) and base64
|
|
20
|
+
* otherwise, and the status the server held when the turn returned.
|
|
21
|
+
*
|
|
22
|
+
* What each fixture proves on the CURRENT engine:
|
|
23
|
+
* - sqlite, end to end: the rows are loaded into the session's checkpoint
|
|
24
|
+
* file, the server-held status is seeded, APPROVE is decided as
|
|
25
|
+
* `SubmitApproval` would, and the reinvocation resumes INSIDE the gate — the
|
|
26
|
+
* command runs exactly once, the run closes COMPLETED, and the to-do list
|
|
27
|
+
* the old engine wrote survives.
|
|
28
|
+
* - http: the same stored bytes served through the proxy wire
|
|
29
|
+
* (`HttpCheckpointSaver`) load into the same checkpoint tuple the sqlite
|
|
30
|
+
* saver loads — the graph resumes from the tuple, so the sqlite resume above
|
|
31
|
+
* carries over to cloud sessions.
|
|
32
|
+
*
|
|
33
|
+
* Recording a fixture (only on the engine that is about to be left behind,
|
|
34
|
+
* before its upgrade lands):
|
|
35
|
+
* RECORD_ENGINE_UPGRADE_FIXTURE=1 npx vitest run src/activities/execute-deep-agent/__tests__/hermetic/resume-across-engine-upgrade.test.ts
|
|
36
|
+
* The recording arm names the file after the installed deepagents version and
|
|
37
|
+
* refuses to overwrite one that exists.
|
|
38
|
+
*
|
|
39
|
+
* Resume is one-way: `@langchain/langgraph` 1.4.9 and later write Topic
|
|
40
|
+
* channels (`__pregel_tasks` among them) as a flat list that 1.3.x reads as
|
|
41
|
+
* `[seen, values]`, so a checkpoint written by this engine does not resume on
|
|
42
|
+
* the 1.3 line. Nothing here pretends otherwise.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
46
|
+
import { createRequire } from "node:module";
|
|
47
|
+
import { dirname, join } from "node:path";
|
|
48
|
+
import { fileURLToPath } from "node:url";
|
|
49
|
+
import { DatabaseSync } from "node:sqlite";
|
|
50
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
51
|
+
import { fromJson, toJson, type JsonValue } from "@bufbuild/protobuf";
|
|
52
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
53
|
+
import {
|
|
54
|
+
ApprovalAction,
|
|
55
|
+
ExecutionPhase,
|
|
56
|
+
TodoStatus,
|
|
57
|
+
ToolCallStatus,
|
|
58
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
59
|
+
|
|
60
|
+
vi.mock("../../../../shared/model-client.js", async () =>
|
|
61
|
+
(await import("../../__test-utils__/scripted-model-module.js")).scriptedModelClientModule(),
|
|
62
|
+
);
|
|
63
|
+
vi.mock("../../../../client/stigmer-client.js", async () =>
|
|
64
|
+
(await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
import {
|
|
68
|
+
ScriptedClock,
|
|
69
|
+
createHermeticEnvironment,
|
|
70
|
+
type HermeticEnvironment,
|
|
71
|
+
} from "../../../../__test-utils__/hermetic-activity.js";
|
|
72
|
+
import { stubRegistryFetch } from "../../../../__test-utils__/model-registry-fixture.js";
|
|
73
|
+
import { getCheckpointDbPath } from "../../../../shared/workspace/platform-dir.js";
|
|
74
|
+
import { SqliteCheckpointSaver } from "../../../../shared/checkpointer/sqlite-saver.js";
|
|
75
|
+
import { HttpCheckpointSaver } from "../../../../shared/checkpointer/http-saver.js";
|
|
76
|
+
import {
|
|
77
|
+
FIXTURE,
|
|
78
|
+
beginDeepAgentScenario,
|
|
79
|
+
deepAgentExecutionRecord,
|
|
80
|
+
runDeepAgentTurn,
|
|
81
|
+
} from "../../__test-utils__/hermetic-deep-agent.js";
|
|
82
|
+
import type { ScriptedTurn } from "../../__test-utils__/scripted-model.js";
|
|
83
|
+
import { CLOSING_TURN, EXECUTE_CALL_A, GATED_OPENING_TURN } from "../../__test-utils__/hitl-script.js";
|
|
84
|
+
|
|
85
|
+
const USER_MESSAGE = "Plan it, then run the command for me.";
|
|
86
|
+
const DECIDED_AT = "2026-01-01T00:00:30.000Z";
|
|
87
|
+
const RESUME_AFTER_MS = 60_000;
|
|
88
|
+
const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
89
|
+
const FIXTURE_PREFIX = "paused-session.deepagents-";
|
|
90
|
+
const RECORDING = process.env.RECORD_ENGINE_UPGRADE_FIXTURE === "1";
|
|
91
|
+
|
|
92
|
+
const TODOS = [
|
|
93
|
+
{ content: "Run the command", status: "in_progress" },
|
|
94
|
+
{ content: "Report the result", status: "pending" },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** Round 0: the to-do list, so the fixture carries the `todos` channel an engine writes. */
|
|
98
|
+
const TODO_TURN: ScriptedTurn = {
|
|
99
|
+
text: "Planning the two steps.",
|
|
100
|
+
toolCalls: [{ id: "call-hermetic-todo-0001", name: "write_todos", args: { todos: TODOS } }],
|
|
101
|
+
usage: { inputTokens: 1_200, outputTokens: 40 },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const SCRIPT = () => ({ turns: [TODO_TURN, GATED_OPENING_TURN, CLOSING_TURN] });
|
|
105
|
+
|
|
106
|
+
/** A blob as the fixture stores it: readable when the serializer wrote JSON. */
|
|
107
|
+
interface StoredBlob {
|
|
108
|
+
readonly encoding: "utf8" | "base64";
|
|
109
|
+
readonly data: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface CheckpointRow {
|
|
113
|
+
readonly thread_id: string;
|
|
114
|
+
readonly checkpoint_ns: string;
|
|
115
|
+
readonly checkpoint_id: string;
|
|
116
|
+
readonly parent_checkpoint_id: string | null;
|
|
117
|
+
readonly type: string | null;
|
|
118
|
+
readonly checkpoint: StoredBlob;
|
|
119
|
+
readonly metadata: StoredBlob;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface WriteRow {
|
|
123
|
+
readonly thread_id: string;
|
|
124
|
+
readonly checkpoint_ns: string;
|
|
125
|
+
readonly checkpoint_id: string;
|
|
126
|
+
readonly task_id: string;
|
|
127
|
+
readonly idx: number;
|
|
128
|
+
readonly channel: string;
|
|
129
|
+
readonly type: string | null;
|
|
130
|
+
readonly value: StoredBlob;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface PausedSessionFixture {
|
|
134
|
+
/** The engine that wrote the rows. */
|
|
135
|
+
readonly recordedWith: Readonly<Record<string, string>>;
|
|
136
|
+
readonly threadId: string;
|
|
137
|
+
readonly checkpoints: readonly CheckpointRow[];
|
|
138
|
+
readonly writes: readonly WriteRow[];
|
|
139
|
+
/** The status the server held when the paused turn returned (`toJson` form). */
|
|
140
|
+
readonly status: JsonValue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function installedVersion(pkg: string): string {
|
|
144
|
+
const require = createRequire(import.meta.url);
|
|
145
|
+
let dir = dirname(require.resolve(pkg));
|
|
146
|
+
while (!existsSync(join(dir, "package.json")) || readPackageName(dir) !== pkg) {
|
|
147
|
+
const parent = dirname(dir);
|
|
148
|
+
if (parent === dir) throw new Error(`no package.json found for ${pkg}`);
|
|
149
|
+
dir = parent;
|
|
150
|
+
}
|
|
151
|
+
return (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version: string }).version;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readPackageName(dir: string): string | undefined {
|
|
155
|
+
return (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { name?: string }).name;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function storeBlob(type: string | null, bytes: Uint8Array): StoredBlob {
|
|
159
|
+
return type === "json"
|
|
160
|
+
? { encoding: "utf8", data: Buffer.from(bytes).toString("utf8") }
|
|
161
|
+
: { encoding: "base64", data: Buffer.from(bytes).toString("base64") };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function loadBlob(blob: StoredBlob): Uint8Array {
|
|
165
|
+
return new Uint8Array(Buffer.from(blob.data, blob.encoding));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readRows(dbPath: string): Pick<PausedSessionFixture, "checkpoints" | "writes"> {
|
|
169
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
170
|
+
try {
|
|
171
|
+
const checkpoints = (
|
|
172
|
+
db
|
|
173
|
+
.prepare(
|
|
174
|
+
`SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata
|
|
175
|
+
FROM checkpoints ORDER BY checkpoint_ns, checkpoint_id`,
|
|
176
|
+
)
|
|
177
|
+
.all() as unknown as Array<Omit<CheckpointRow, "checkpoint" | "metadata"> & { checkpoint: Uint8Array; metadata: Uint8Array }>
|
|
178
|
+
).map((r) => ({ ...r, checkpoint: storeBlob(r.type, r.checkpoint), metadata: storeBlob(r.type, r.metadata) }));
|
|
179
|
+
const writes = (
|
|
180
|
+
db
|
|
181
|
+
.prepare(
|
|
182
|
+
`SELECT thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value
|
|
183
|
+
FROM writes ORDER BY checkpoint_ns, checkpoint_id, task_id, idx`,
|
|
184
|
+
)
|
|
185
|
+
.all() as unknown as Array<Omit<WriteRow, "value"> & { value: Uint8Array }>
|
|
186
|
+
).map((r) => ({ ...r, value: storeBlob(r.type, r.value) }));
|
|
187
|
+
return { checkpoints, writes };
|
|
188
|
+
} finally {
|
|
189
|
+
db.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Write a fixture's rows into a checkpoint file whose schema the saver itself created. */
|
|
194
|
+
async function loadRows(dbPath: string, fixture: PausedSessionFixture): Promise<void> {
|
|
195
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
196
|
+
const saver = new SqliteCheckpointSaver(dbPath);
|
|
197
|
+
expect(await saver.getTuple({ configurable: { thread_id: fixture.threadId } }), "a fresh file").toBeUndefined();
|
|
198
|
+
const db = new DatabaseSync(dbPath);
|
|
199
|
+
try {
|
|
200
|
+
const cp = db.prepare(
|
|
201
|
+
`INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
203
|
+
);
|
|
204
|
+
for (const r of fixture.checkpoints) {
|
|
205
|
+
cp.run(r.thread_id, r.checkpoint_ns, r.checkpoint_id, r.parent_checkpoint_id, r.type, loadBlob(r.checkpoint), loadBlob(r.metadata));
|
|
206
|
+
}
|
|
207
|
+
const w = db.prepare(
|
|
208
|
+
`INSERT INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value)
|
|
209
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
210
|
+
);
|
|
211
|
+
for (const r of fixture.writes) {
|
|
212
|
+
w.run(r.thread_id, r.checkpoint_ns, r.checkpoint_id, r.task_id, r.idx, r.channel, r.type, loadBlob(r.value));
|
|
213
|
+
}
|
|
214
|
+
} finally {
|
|
215
|
+
db.close();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The proxy's checkpoint API over a fixture's rows, as `fetch` sees it: the
|
|
221
|
+
* documents the server stores and returns (`$binary` Extended JSON), keyed as
|
|
222
|
+
* the saver queries them.
|
|
223
|
+
*/
|
|
224
|
+
function proxyFetch(fixture: PausedSessionFixture): typeof fetch {
|
|
225
|
+
const binary = (blob: StoredBlob) => ({
|
|
226
|
+
$binary: { base64: Buffer.from(loadBlob(blob)).toString("base64"), subType: "00" },
|
|
227
|
+
});
|
|
228
|
+
const checkpointDoc = (r: CheckpointRow) => ({
|
|
229
|
+
thread_id: r.thread_id,
|
|
230
|
+
checkpoint_ns: r.checkpoint_ns,
|
|
231
|
+
checkpoint_id: r.checkpoint_id,
|
|
232
|
+
parent_checkpoint_id: r.parent_checkpoint_id,
|
|
233
|
+
type: r.type,
|
|
234
|
+
checkpoint: binary(r.checkpoint),
|
|
235
|
+
metadata_type: r.type,
|
|
236
|
+
metadata: binary(r.metadata),
|
|
237
|
+
});
|
|
238
|
+
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
|
|
239
|
+
return (async (input: string | URL | Request) => {
|
|
240
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
241
|
+
const ns = url.searchParams.get("checkpoint_ns") ?? "";
|
|
242
|
+
const rows = fixture.checkpoints.filter((r) => r.thread_id === url.searchParams.get("thread_id") && r.checkpoint_ns === ns);
|
|
243
|
+
if (url.pathname.endsWith("/checkpoint")) {
|
|
244
|
+
const id = url.searchParams.get("checkpoint_id");
|
|
245
|
+
const row = id ? rows.find((r) => r.checkpoint_id === id) : [...rows].sort((a, b) => b.checkpoint_id.localeCompare(a.checkpoint_id))[0];
|
|
246
|
+
return row ? json(checkpointDoc(row)) : new Response("", { status: 404 });
|
|
247
|
+
}
|
|
248
|
+
if (url.pathname.endsWith("/writes")) {
|
|
249
|
+
const id = url.searchParams.get("checkpoint_id");
|
|
250
|
+
const writes = fixture.writes
|
|
251
|
+
.filter((w) => w.thread_id === url.searchParams.get("thread_id") && w.checkpoint_ns === ns && w.checkpoint_id === id)
|
|
252
|
+
.map((w) => ({ ...w, value: binary(w.value) }));
|
|
253
|
+
return json({ writes });
|
|
254
|
+
}
|
|
255
|
+
throw new Error(`the fixture proxy serves no ${url.pathname}`);
|
|
256
|
+
}) as typeof fetch;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function committedFixtures(): string[] {
|
|
260
|
+
if (!existsSync(FIXTURE_DIR)) return [];
|
|
261
|
+
return readdirSync(FIXTURE_DIR)
|
|
262
|
+
.filter((f) => f.startsWith(FIXTURE_PREFIX) && f.endsWith(".json"))
|
|
263
|
+
.sort();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
describe("ExecuteDeepAgent hermetic — a paused session resumes across an engine upgrade", () => {
|
|
267
|
+
let env: HermeticEnvironment;
|
|
268
|
+
let registry: ReturnType<typeof stubRegistryFetch>;
|
|
269
|
+
const clock = new ScriptedClock();
|
|
270
|
+
|
|
271
|
+
beforeAll(() => {
|
|
272
|
+
env = createHermeticEnvironment();
|
|
273
|
+
registry = stubRegistryFetch();
|
|
274
|
+
clock.install();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
afterAll(() => {
|
|
278
|
+
clock.uninstall();
|
|
279
|
+
registry.restore();
|
|
280
|
+
env.dispose();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it.runIf(RECORDING)("records the paused session on the installed engine", async () => {
|
|
284
|
+
const version = installedVersion("deepagents");
|
|
285
|
+
const target = join(FIXTURE_DIR, `${FIXTURE_PREFIX}${version}.json`);
|
|
286
|
+
expect(existsSync(target), `${target} is frozen; a fixture is recorded once, on the engine it names`).toBe(false);
|
|
287
|
+
|
|
288
|
+
clock.reset();
|
|
289
|
+
const record = deepAgentExecutionRecord({ message: USER_MESSAGE });
|
|
290
|
+
const scenario = beginDeepAgentScenario({ env, clock, record, checkpointer: "sqlite", script: SCRIPT });
|
|
291
|
+
const turn = await runDeepAgentTurn(scenario, { turnSeq: 0 });
|
|
292
|
+
expect(turn.outcome.kind).toBe("returned");
|
|
293
|
+
expect(record.lastFullStatus?.phase).toBe(ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL);
|
|
294
|
+
expect(record.lastFullStatus?.todos).not.toEqual({});
|
|
295
|
+
|
|
296
|
+
const fixture: PausedSessionFixture = {
|
|
297
|
+
recordedWith: {
|
|
298
|
+
deepagents: version,
|
|
299
|
+
"@langchain/langgraph": installedVersion("@langchain/langgraph"),
|
|
300
|
+
"@langchain/langgraph-checkpoint": installedVersion("@langchain/langgraph-checkpoint"),
|
|
301
|
+
"@langchain/core": installedVersion("@langchain/core"),
|
|
302
|
+
},
|
|
303
|
+
threadId: FIXTURE.threadId,
|
|
304
|
+
...readRows(getCheckpointDbPath(FIXTURE.sessionId)),
|
|
305
|
+
status: toJson(AgentExecutionStatusSchema, record.lastFullStatus!),
|
|
306
|
+
};
|
|
307
|
+
mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
308
|
+
writeFileSync(target, JSON.stringify(fixture, null, 2) + "\n");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe.skipIf(RECORDING).each(committedFixtures())("%s", (file) => {
|
|
312
|
+
const fixture = JSON.parse(readFileSync(join(FIXTURE_DIR, file), "utf8")) as PausedSessionFixture;
|
|
313
|
+
|
|
314
|
+
it("resumes inside the gate on sqlite: the command runs once, the old to-do list survives", async () => {
|
|
315
|
+
// ── Arrange: the old engine's rows and the status the server held ─────
|
|
316
|
+
clock.reset();
|
|
317
|
+
const record = deepAgentExecutionRecord({ message: USER_MESSAGE });
|
|
318
|
+
const scenario = beginDeepAgentScenario({ env, clock, record, checkpointer: "sqlite", script: SCRIPT });
|
|
319
|
+
await loadRows(getCheckpointDbPath(FIXTURE.sessionId), fixture);
|
|
320
|
+
record.applyStatusUpdate(fromJson(AgentExecutionStatusSchema, fixture.status));
|
|
321
|
+
// The approval arrives after the pause, as it does live. Checkpoint ids
|
|
322
|
+
// are time-ordered, and the recorded turn ran on this same scripted
|
|
323
|
+
// clock from its epoch, so the resumed turn starts a minute on: its new
|
|
324
|
+
// checkpoints must sort after the paused one they continue.
|
|
325
|
+
clock.tick(RESUME_AFTER_MS);
|
|
326
|
+
expect(record.waitingToolCalls().map((tc) => tc.id)).toEqual([EXECUTE_CALL_A.id]);
|
|
327
|
+
|
|
328
|
+
// ── Between turns: what SubmitApproval does to the row ─────────────────
|
|
329
|
+
expect(record.decideWaitingToolCalls(ApprovalAction.APPROVE, DECIDED_AT)).toBe(1);
|
|
330
|
+
|
|
331
|
+
// ── Act: the reinvocation on THIS engine ───────────────────────────────
|
|
332
|
+
const turn = await runDeepAgentTurn(scenario, { turnSeq: 1 });
|
|
333
|
+
|
|
334
|
+
// ── Assert ─────────────────────────────────────────────────────────────
|
|
335
|
+
expect(turn.outcome.kind).toBe("returned");
|
|
336
|
+
expect((turn.outcome as { value: Record<string, unknown> }).value.phase).toBe("EXECUTION_COMPLETED");
|
|
337
|
+
const final = record.lastFullStatus!;
|
|
338
|
+
const rows = final.messages.flatMap((m) => m.toolCalls).filter((tc) => tc.id === EXECUTE_CALL_A.id);
|
|
339
|
+
expect(rows, "exactly one copy of the gated call — a resume, not a replay").toHaveLength(1);
|
|
340
|
+
expect(rows[0].status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
341
|
+
expect(rows[0].result, "the command ran on this engine").toContain("hermetic-a");
|
|
342
|
+
expect(
|
|
343
|
+
Object.values(final.todos).map((t) => [t.content, t.status]),
|
|
344
|
+
"the to-do list the old engine wrote",
|
|
345
|
+
).toEqual([
|
|
346
|
+
["Run the command", TodoStatus.TODO_IN_PROGRESS],
|
|
347
|
+
["Report the result", TodoStatus.TODO_PENDING],
|
|
348
|
+
]);
|
|
349
|
+
expect(final.messages.at(-1)?.content).toBe(CLOSING_TURN.text);
|
|
350
|
+
expect(registry.urls.every((u) => u.includes("/model-registry"))).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("loads the same stored bytes into the same tuple through the proxy wire", async () => {
|
|
354
|
+
const dbPath = join(env.home, "resume-across-engine-upgrade", file.replace(/\.json$/, ".db"));
|
|
355
|
+
await loadRows(dbPath, fixture);
|
|
356
|
+
const config = { configurable: { thread_id: fixture.threadId, checkpoint_ns: "" } };
|
|
357
|
+
const fromSqlite = await new SqliteCheckpointSaver(dbPath).getTuple(config);
|
|
358
|
+
|
|
359
|
+
const realFetch = globalThis.fetch;
|
|
360
|
+
globalThis.fetch = proxyFetch(fixture);
|
|
361
|
+
try {
|
|
362
|
+
const fromProxy = await new HttpCheckpointSaver("http://proxy.invalid", { current: "t" }, { maxRetries: 0 }).getTuple(config);
|
|
363
|
+
expect(fromSqlite, "the sqlite saver loads the old rows").toBeDefined();
|
|
364
|
+
expect(fromProxy?.checkpoint).toEqual(fromSqlite?.checkpoint);
|
|
365
|
+
expect(fromProxy?.metadata).toEqual(fromSqlite?.metadata);
|
|
366
|
+
expect(fromProxy?.parentConfig).toEqual(fromSqlite?.parentConfig);
|
|
367
|
+
expect(fromProxy?.pendingWrites).toEqual(fromSqlite?.pendingWrites);
|
|
368
|
+
expect(fromSqlite?.pendingWrites?.length, "the paused turn left its interrupt as a pending write").toBeGreaterThan(0);
|
|
369
|
+
} finally {
|
|
370
|
+
globalThis.fetch = realFetch;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -47,6 +47,13 @@
|
|
|
47
47
|
* the blocks array the image offload reads — where they were the whole
|
|
48
48
|
* serialized Command. Those two lines are the whole of that hunk.
|
|
49
49
|
*
|
|
50
|
+
* Since deepagents 1.14 (2026-09-28): the `task` tool returns the
|
|
51
|
+
* sub-agent's last non-empty text as a plain string, so the same two lines
|
|
52
|
+
* read `"The fixture value is forty-two."` instead of the blocks array. The
|
|
53
|
+
* status offload is size-driven and treats either form the same; an image
|
|
54
|
+
* could only ever have ridden a `task` result inside the sub-agent's final
|
|
55
|
+
* AI message. Those two lines are the whole of that hunk.
|
|
56
|
+
*
|
|
50
57
|
* Also since #1097: the `task` row sits on the AI message whose text
|
|
51
58
|
* delegated ("Delegating to the helper."), not on an empty AI message of its
|
|
52
59
|
* own after it — two root AI messages (the delegating text with the row, the
|
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
* thinking mode (one writer of the summary, the runtime). Nothing
|
|
34
34
|
* else in this golden moved with the flip.
|
|
35
35
|
*
|
|
36
|
+
* Since deepagents 1.12 (landed with 1.14, 2026-09-28): `read_file`
|
|
37
|
+
* returns the file's lines unnumbered under a status header,
|
|
38
|
+
* `@@ lines 1-3 of 3 @@`, where it returned `cat -n` numbered lines. The
|
|
39
|
+
* row's `result` is that one line of the golden; the clients' read view
|
|
40
|
+
* drops the header (`sdk/typescript/src/execution/tool-view.ts`).
|
|
41
|
+
*
|
|
36
42
|
* Regenerate ONLY after a deliberate behavior change:
|
|
37
43
|
* npx vitest run src/activities/execute-deep-agent/__tests__/hermetic -u
|
|
38
44
|
*/
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* relative paths to VIRTUAL-absolute at our seam, before enforcement sees
|
|
12
12
|
* them. The read boundary #528 built out of rules is structural since #754:
|
|
13
13
|
* the backend is virtual-rooted, so every expressible path resolves inside
|
|
14
|
-
* the workspace — an out-of-root name is simply nonexistent
|
|
15
|
-
*
|
|
14
|
+
* the workspace — an out-of-root name is simply nonexistent. Platform-dir
|
|
15
|
+
* reads stay addressable in-root at `.stigmer/…`: the virtual-rooted backend
|
|
16
|
+
* refuses to follow the workspace link out of the root, so the platform dir is
|
|
17
|
+
* mounted read-only at that prefix (`platform-route.ts`).
|
|
16
18
|
*
|
|
17
19
|
* The graph here is composed exactly the way turn-setup.ts composes the parent:
|
|
18
20
|
* the PRODUCTION buildMiddlewareStack (pathNormalization present on every
|
|
@@ -36,30 +38,36 @@ import { createDeepAgent } from "deepagents";
|
|
|
36
38
|
import { buildPlanModePermissions } from "../../../shared/plan-mode-permissions.js";
|
|
37
39
|
import { buildMiddlewareStack } from "../../../middleware/index.js";
|
|
38
40
|
import { createCasCaptureBackend } from "../cas-capture-backend.js";
|
|
41
|
+
import { mountPlatformRoute } from "../platform-route.js";
|
|
39
42
|
import { CasCaptureObserver } from "../cas-capture-observer.js";
|
|
40
43
|
import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
|
|
41
44
|
|
|
42
45
|
const SEEDED_CONTENT = "PLAN_MODE_README_TOKEN: hello from the seeded file";
|
|
43
46
|
const OUTSIDE_CONTENT = "OUT_OF_ROOT_SECRET_TOKEN: must never cross the boundary";
|
|
44
|
-
const PLATFORM_CONTENT = "PLATFORM_SKILL_TOKEN: reached through the .stigmer
|
|
47
|
+
const PLATFORM_CONTENT = "PLATFORM_SKILL_TOKEN: reached through the .stigmer route";
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* Build a plan-mode parent graph the way turn-setup.ts does: the production
|
|
48
51
|
* middleware stack with pathNormalization set (derived, like the graph's
|
|
49
52
|
* permissions, from the plan-mode rules), a filesystem-only CAS capture
|
|
50
|
-
* backend (no shellEnv — plan mode clears it)
|
|
51
|
-
*
|
|
53
|
+
* backend (no shellEnv — plan mode clears it) with the read-only platform
|
|
54
|
+
* route mounted when the workspace link points at `platformDir`, and the
|
|
55
|
+
* production buildPlanModePermissions rules on the graph.
|
|
52
56
|
*/
|
|
53
57
|
async function buildPlanModeParent(
|
|
54
58
|
root: string,
|
|
55
59
|
observer: CasCaptureObserver,
|
|
56
60
|
script: ScriptSelector,
|
|
61
|
+
platformDir?: string,
|
|
57
62
|
) {
|
|
58
63
|
const { middleware } = buildMiddlewareStack({
|
|
59
64
|
approvalGate: null,
|
|
60
65
|
pathNormalization: { rootDir: root },
|
|
61
66
|
});
|
|
62
|
-
const backend = await createCasCaptureBackend({ rootDir: root, observer })
|
|
67
|
+
const backend = await mountPlatformRoute(await createCasCaptureBackend({ rootDir: root, observer }), {
|
|
68
|
+
workspaceDir: root,
|
|
69
|
+
platformDir,
|
|
70
|
+
});
|
|
63
71
|
return createDeepAgent({
|
|
64
72
|
model: new ScriptedModel(script),
|
|
65
73
|
checkpointer: new MemorySaver() as never,
|
|
@@ -215,7 +223,7 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
|
|
|
215
223
|
});
|
|
216
224
|
|
|
217
225
|
async function invokeOnce(script: ScriptSelector, threadId: string) {
|
|
218
|
-
const agent = await buildPlanModeParent(root, observer, script);
|
|
226
|
+
const agent = await buildPlanModeParent(root, observer, script, platform);
|
|
219
227
|
return (await agent.invoke(
|
|
220
228
|
{ messages: [new HumanMessage({ content: "go" })] },
|
|
221
229
|
{ configurable: { thread_id: threadId }, recursionLimit: 50 },
|
|
@@ -256,7 +264,7 @@ describe("plan-mode workspace read boundary (issue #528)", () => {
|
|
|
256
264
|
expect(lsResult).not.toMatch(/permission denied/i);
|
|
257
265
|
});
|
|
258
266
|
|
|
259
|
-
it("reads platform-dir material
|
|
267
|
+
it("reads platform-dir material at .stigmer — in-root strings, out-of-root bytes", async () => {
|
|
260
268
|
const result = await invokeOnce(
|
|
261
269
|
() => ({
|
|
262
270
|
toolCalls: [
|