open-multi-agent-kit 0.90.9 → 0.92.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.
Files changed (131) hide show
  1. package/CHANGELOG.md +39 -4
  2. package/README.md +14 -1
  3. package/dist/core/agent-session.d.ts +27 -0
  4. package/dist/core/agent-session.d.ts.map +1 -1
  5. package/dist/core/agent-session.js +196 -32
  6. package/dist/core/agent-session.js.map +1 -1
  7. package/dist/core/command-safety.d.ts.map +1 -1
  8. package/dist/core/command-safety.js +0 -58
  9. package/dist/core/command-safety.js.map +1 -1
  10. package/dist/core/compaction/transaction.d.ts.map +1 -1
  11. package/dist/core/compaction/transaction.js +7 -3
  12. package/dist/core/compaction/transaction.js.map +1 -1
  13. package/dist/core/context-budget-headroom-candidates.d.ts.map +1 -1
  14. package/dist/core/context-budget-headroom-candidates.js +21 -11
  15. package/dist/core/context-budget-headroom-candidates.js.map +1 -1
  16. package/dist/core/context-budget-system-prompt-items.d.ts +1 -0
  17. package/dist/core/context-budget-system-prompt-items.d.ts.map +1 -1
  18. package/dist/core/context-budget-system-prompt-items.js +7 -8
  19. package/dist/core/context-budget-system-prompt-items.js.map +1 -1
  20. package/dist/core/footer-data-provider.d.ts +7 -1
  21. package/dist/core/footer-data-provider.d.ts.map +1 -1
  22. package/dist/core/footer-data-provider.js +12 -0
  23. package/dist/core/footer-data-provider.js.map +1 -1
  24. package/dist/core/index.d.ts +1 -0
  25. package/dist/core/index.d.ts.map +1 -1
  26. package/dist/core/index.js +1 -0
  27. package/dist/core/index.js.map +1 -1
  28. package/dist/core/loadouts.d.ts.map +1 -1
  29. package/dist/core/loadouts.js +27 -48
  30. package/dist/core/loadouts.js.map +1 -1
  31. package/dist/core/metrics-sampler.d.ts +20 -0
  32. package/dist/core/metrics-sampler.d.ts.map +1 -1
  33. package/dist/core/metrics-sampler.js +52 -0
  34. package/dist/core/metrics-sampler.js.map +1 -1
  35. package/dist/core/model-registry.d.ts.map +1 -1
  36. package/dist/core/model-registry.js +4 -1
  37. package/dist/core/model-registry.js.map +1 -1
  38. package/dist/core/model-resolver.d.ts.map +1 -1
  39. package/dist/core/model-resolver.js +15 -6
  40. package/dist/core/model-resolver.js.map +1 -1
  41. package/dist/core/provider-resilience.d.ts +51 -0
  42. package/dist/core/provider-resilience.d.ts.map +1 -0
  43. package/dist/core/provider-resilience.js +86 -0
  44. package/dist/core/provider-resilience.js.map +1 -0
  45. package/dist/core/run-journal.d.ts.map +1 -1
  46. package/dist/core/run-journal.js +14 -22
  47. package/dist/core/run-journal.js.map +1 -1
  48. package/dist/core/session-termination.d.ts +8 -2
  49. package/dist/core/session-termination.d.ts.map +1 -1
  50. package/dist/core/session-termination.js +45 -3
  51. package/dist/core/session-termination.js.map +1 -1
  52. package/dist/core/settings-manager.d.ts +30 -0
  53. package/dist/core/settings-manager.d.ts.map +1 -1
  54. package/dist/core/settings-manager.js +17 -0
  55. package/dist/core/settings-manager.js.map +1 -1
  56. package/dist/core/system-prompt.d.ts.map +1 -1
  57. package/dist/core/system-prompt.js +35 -30
  58. package/dist/core/system-prompt.js.map +1 -1
  59. package/dist/core/tools/grep.d.ts.map +1 -1
  60. package/dist/core/tools/grep.js +47 -0
  61. package/dist/core/tools/grep.js.map +1 -1
  62. package/dist/core/tools/omp-pure-seams.d.ts +41 -0
  63. package/dist/core/tools/omp-pure-seams.d.ts.map +1 -0
  64. package/dist/core/tools/omp-pure-seams.js +142 -0
  65. package/dist/core/tools/omp-pure-seams.js.map +1 -0
  66. package/dist/core/tools/omp-seam-runtime.d.ts +100 -0
  67. package/dist/core/tools/omp-seam-runtime.d.ts.map +1 -0
  68. package/dist/core/tools/omp-seam-runtime.js +40 -0
  69. package/dist/core/tools/omp-seam-runtime.js.map +1 -0
  70. package/dist/core/tools/read.d.ts.map +1 -1
  71. package/dist/core/tools/read.js +34 -1
  72. package/dist/core/tools/read.js.map +1 -1
  73. package/dist/main.d.ts.map +1 -1
  74. package/dist/main.js +112 -11
  75. package/dist/main.js.map +1 -1
  76. package/dist/modes/interactive/components/footer.d.ts +1 -1
  77. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  78. package/dist/modes/interactive/components/footer.js +11 -8
  79. package/dist/modes/interactive/components/footer.js.map +1 -1
  80. package/dist/modes/interactive/theme/omk-aurora-dark.json +80 -0
  81. package/dist/modes/interactive/theme/omk-aurora-light.json +80 -0
  82. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  83. package/dist/modes/interactive/theme/theme.js +5 -0
  84. package/dist/modes/interactive/theme/theme.js.map +1 -1
  85. package/dist/modes/print-mode.d.ts.map +1 -1
  86. package/dist/modes/print-mode.js +9 -0
  87. package/dist/modes/print-mode.js.map +1 -1
  88. package/docs/adaptorch-preview-spec.md +174 -0
  89. package/docs/adaptorch-preview.md +2 -2
  90. package/docs/compaction.md +13 -13
  91. package/docs/correctness-wall.md +13 -5
  92. package/docs/custom-provider.md +8 -8
  93. package/docs/development.md +2 -2
  94. package/docs/extensions.md +8 -8
  95. package/docs/json.md +4 -4
  96. package/docs/provider-resilience.md +87 -0
  97. package/docs/providers.md +1 -1
  98. package/docs/session-format.md +5 -5
  99. package/docs/settings.md +30 -0
  100. package/docs/skills.md +2 -0
  101. package/docs/themes.md +4 -2
  102. package/docs/tui.md +4 -4
  103. package/docs/usage.md +4 -0
  104. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  105. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  106. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  107. package/examples/extensions/gondolin/package-lock.json +2 -2
  108. package/examples/extensions/gondolin/package.json +1 -1
  109. package/examples/extensions/sandbox/package-lock.json +2 -2
  110. package/examples/extensions/sandbox/package.json +1 -1
  111. package/examples/extensions/subagent/README.md +35 -10
  112. package/examples/extensions/subagent/adaptive-agent-runtime.test.ts +234 -0
  113. package/examples/extensions/subagent/adaptive-agent-runtime.ts +240 -0
  114. package/examples/extensions/subagent/adaptive-result.ts +112 -0
  115. package/examples/extensions/subagent/checkpoint-runtime.test.ts +125 -0
  116. package/examples/extensions/subagent/checkpoint-runtime.ts +198 -0
  117. package/examples/extensions/subagent/deadline-budget.test.ts +135 -0
  118. package/examples/extensions/subagent/deadline-budget.ts +242 -0
  119. package/examples/extensions/subagent/deadline-profile-store.test.ts +72 -0
  120. package/examples/extensions/subagent/deadline-profile-store.ts +172 -0
  121. package/examples/extensions/subagent/fixtures/fake-omk-json.mjs +13 -0
  122. package/examples/extensions/subagent/fixtures/hanging-process-tree.mjs +40 -0
  123. package/examples/extensions/subagent/index.ts +253 -137
  124. package/examples/extensions/subagent/managed-process.test.ts +178 -0
  125. package/examples/extensions/subagent/managed-process.ts +236 -0
  126. package/examples/extensions/subagent/subagent-extension-smoke.test.ts +112 -0
  127. package/examples/extensions/subagent/subagent-runtime-types.ts +76 -0
  128. package/examples/extensions/with-deps/package-lock.json +2 -2
  129. package/examples/extensions/with-deps/package.json +1 -1
  130. package/npm-shrinkwrap.json +28 -19
  131. package/package.json +6 -5
@@ -0,0 +1,240 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import {
4
+ attemptOutcome,
5
+ createBaseResult,
6
+ finalizeResult,
7
+ mergeForUpdate,
8
+ mergeResult,
9
+ parseProviderModel,
10
+ resultOutput,
11
+ } from "./adaptive-result.ts";
12
+ import {
13
+ buildCheckpointTask,
14
+ checkpointMadeProgress,
15
+ createCheckpointWorkspace,
16
+ createEmptyCheckpoint,
17
+ readCheckpoint,
18
+ removeCheckpointWorkspace,
19
+ writeCheckpoint,
20
+ } from "./checkpoint-runtime.ts";
21
+ import {
22
+ allocateAttemptBudget,
23
+ type DeadlineProfile,
24
+ type ExecutionBudget,
25
+ estimateTaskDemand,
26
+ planTaskShards,
27
+ recommendAttemptCutoff,
28
+ remainingExecutionMs,
29
+ } from "./deadline-budget.ts";
30
+ import type { DeadlineProfileStore } from "./deadline-profile-store.ts";
31
+ import type {
32
+ DeadlineAttemptMetadata,
33
+ DeadlineOutcome,
34
+ SingleResult,
35
+ SubagentAttemptResult,
36
+ } from "./subagent-runtime-types.ts";
37
+
38
+ export interface RunAttemptInput {
39
+ readonly logicalTask: string;
40
+ readonly task: string;
41
+ readonly cutoffMs: number;
42
+ readonly attempt: number;
43
+ readonly shardId: string;
44
+ readonly shardIndex: number;
45
+ readonly shardCount: number;
46
+ readonly checkpointFilePath: string;
47
+ readonly onUpdate?: (partial: SingleResult) => void;
48
+ }
49
+
50
+ interface AdaptiveRuntimePolicy {
51
+ readonly fallbackAttemptMs: number;
52
+ readonly minimumAttemptMs: number;
53
+ readonly maxTokensPerShard: number;
54
+ }
55
+
56
+ interface RunAdaptiveAgentOptions {
57
+ readonly agentName: string;
58
+ readonly agentSource: "user" | "project";
59
+ readonly logicalTask: string;
60
+ readonly model?: string;
61
+ readonly step?: number;
62
+ readonly signal?: AbortSignal;
63
+ readonly budget: ExecutionBudget;
64
+ readonly pendingSequentialTasks: number;
65
+ readonly checkpointPrefix?: string;
66
+ readonly profileStore: DeadlineProfileStore;
67
+ readonly policy: AdaptiveRuntimePolicy;
68
+ readonly onUpdate?: (partial: SingleResult) => void;
69
+ readonly runAttempt: (input: RunAttemptInput) => Promise<SubagentAttemptResult>;
70
+ }
71
+
72
+ export async function runAdaptiveAgent(options: RunAdaptiveAgentOptions): Promise<SingleResult> {
73
+ const startedAtMs = Date.now();
74
+ let identity = parseProviderModel(options.model);
75
+ let profile = await options.profileStore.profileFor(identity.provider, identity.model);
76
+ const initialCutoff = cutoffFor(profile, options, remainingExecutionMs(options.budget));
77
+ const demand = estimateTaskDemand(options.logicalTask);
78
+ const shards = planTaskShards(options.logicalTask, demand, {
79
+ attemptCutoffMs: initialCutoff,
80
+ maxTaskShards: options.budget.maxTaskShards,
81
+ maxTokensPerShard: options.policy.maxTokensPerShard,
82
+ });
83
+ const taskAllocation = allocateAttemptBudget({
84
+ budget: options.budget,
85
+ pendingSequentialTasks: options.pendingSequentialTasks,
86
+ pendingShards: 1,
87
+ recommendedCutoffMs: remainingExecutionMs(options.budget),
88
+ minimumAttemptMs: 1,
89
+ });
90
+ const taskBudget: ExecutionBudget = {
91
+ ...options.budget,
92
+ hardDeadlineMs: Math.max(1, taskAllocation.taskDeadlineAtMs - startedAtMs),
93
+ deadlineAtMs: Math.min(options.budget.deadlineAtMs, taskAllocation.taskDeadlineAtMs),
94
+ };
95
+ const aggregate = createBaseResult(options);
96
+ const attempts: DeadlineAttemptMetadata[] = [];
97
+ const completedShardIds: string[] = [];
98
+ let resumeCount = 0;
99
+ let duplicateResumeBlocked = false;
100
+ let lastCheckpoint: Awaited<ReturnType<typeof readCheckpoint>> | undefined;
101
+ let terminalOutcome: DeadlineOutcome = "budget-exhausted";
102
+ const checkpointPrefix = options.checkpointPrefix ?? path.join(os.tmpdir(), "omk-subagent-checkpoint-");
103
+ const workspace = await createCheckpointWorkspace(checkpointPrefix);
104
+
105
+ try {
106
+ outer: for (const shard of shards) {
107
+ let shardAttempt = 0;
108
+ let shardMessageCount = 0;
109
+ let checkpoint: typeof lastCheckpoint;
110
+ while (true) {
111
+ const pendingShards = shards.length - completedShardIds.length;
112
+ const recommended = cutoffFor(profile, options, remainingExecutionMs(taskBudget));
113
+ const allocation = allocateAttemptBudget({
114
+ budget: taskBudget,
115
+ pendingSequentialTasks: 1,
116
+ pendingShards,
117
+ recommendedCutoffMs: recommended,
118
+ minimumAttemptMs: options.policy.minimumAttemptMs,
119
+ });
120
+ if (allocation.cutoffMs === 0 || options.signal?.aborted) {
121
+ terminalOutcome = options.signal?.aborted ? "aborted" : "budget-exhausted";
122
+ break outer;
123
+ }
124
+
125
+ const attemptNumber = shardAttempt + 1;
126
+ const task = buildCheckpointTask({
127
+ originalTask: options.logicalTask,
128
+ shardTask: shard.task,
129
+ shardId: shard.id,
130
+ shardIndex: shard.index,
131
+ shardCount: shards.length,
132
+ attempt: attemptNumber,
133
+ cutoffMs: allocation.cutoffMs,
134
+ checkpointFilePath: workspace.filePath,
135
+ ...(checkpoint === undefined ? {} : { checkpoint }),
136
+ });
137
+ const attempt = await options.runAttempt({
138
+ logicalTask: options.logicalTask,
139
+ task,
140
+ cutoffMs: allocation.cutoffMs,
141
+ attempt: attemptNumber,
142
+ shardId: shard.id,
143
+ shardIndex: shard.index,
144
+ shardCount: shards.length,
145
+ checkpointFilePath: workspace.filePath,
146
+ onUpdate: options.onUpdate
147
+ ? (partial) => options.onUpdate?.(mergeForUpdate(aggregate, partial))
148
+ : undefined,
149
+ });
150
+ mergeResult(aggregate, attempt.result);
151
+ shardMessageCount += attempt.result.messages.length;
152
+ const outcome = attemptOutcome(attempt);
153
+ const observedIdentity = parseProviderModel(attempt.result.model ?? options.model);
154
+ if (observedIdentity.provider !== identity.provider || observedIdentity.model !== identity.model) {
155
+ identity = observedIdentity;
156
+ profile = await options.profileStore.profileFor(identity.provider, identity.model);
157
+ }
158
+ try {
159
+ profile = await options.profileStore.record({
160
+ provider: identity.provider,
161
+ model: identity.model,
162
+ outcome,
163
+ elapsedMs: attempt.process.elapsedMs,
164
+ estimatedTokens: demand.estimatedTokens,
165
+ workUnits: demand.workUnits,
166
+ });
167
+ } catch {
168
+ profile = await options.profileStore.profileFor(identity.provider, identity.model);
169
+ }
170
+ lastCheckpoint = await readCheckpoint(workspace.filePath, {
171
+ messageCount: shardMessageCount,
172
+ outputTail: resultOutput(attempt.result),
173
+ lastEventAtMs: Date.now(),
174
+ });
175
+ attempts.push({
176
+ attempt: attemptNumber,
177
+ shardId: shard.id,
178
+ cutoffMs: allocation.cutoffMs,
179
+ elapsedMs: attempt.process.elapsedMs,
180
+ outcome,
181
+ exitCode: attempt.result.exitCode,
182
+ messageCount: attempt.result.messages.length,
183
+ processReason: attempt.process.reason,
184
+ cleanup: attempt.process.cleanup,
185
+ });
186
+
187
+ if (outcome === "completed") {
188
+ completedShardIds.push(shard.id);
189
+ terminalOutcome = "completed";
190
+ checkpoint = undefined;
191
+ await writeCheckpoint(workspace.filePath, createEmptyCheckpoint());
192
+ continue outer;
193
+ }
194
+ terminalOutcome = outcome;
195
+ if (outcome !== "cutoff" || resumeCount >= taskBudget.maxResumeAttempts) break outer;
196
+ if (!checkpointMadeProgress(checkpoint, lastCheckpoint)) {
197
+ duplicateResumeBlocked = true;
198
+ break outer;
199
+ }
200
+ checkpoint = lastCheckpoint;
201
+ shardAttempt += 1;
202
+ resumeCount += 1;
203
+ }
204
+ }
205
+ } finally {
206
+ await removeCheckpointWorkspace(workspace);
207
+ }
208
+
209
+ const remainingShardIds = shards.map((shard) => shard.id).filter((id) => !completedShardIds.includes(id));
210
+ if (remainingShardIds.length === 0) terminalOutcome = "completed";
211
+ finalizeResult(aggregate, terminalOutcome);
212
+ aggregate.deadline = {
213
+ outcome: terminalOutcome,
214
+ startedAtMs,
215
+ elapsedMs: Date.now() - startedAtMs,
216
+ hardDeadlineMs: taskBudget.hardDeadlineMs,
217
+ estimatedTokens: demand.estimatedTokens,
218
+ workUnits: demand.workUnits,
219
+ predictedMs: demand.predictedMs,
220
+ plannedShardIds: shards.map((shard) => shard.id),
221
+ completedShardIds,
222
+ remainingShardIds,
223
+ resumeCount,
224
+ duplicateResumeBlocked,
225
+ provider: identity.provider,
226
+ model: identity.model,
227
+ profileSamples: profile?.samples ?? 0,
228
+ attempts,
229
+ ...(lastCheckpoint === undefined ? {} : { checkpoint: lastCheckpoint }),
230
+ };
231
+ return aggregate;
232
+ }
233
+
234
+ function cutoffFor(profile: DeadlineProfile | undefined, options: RunAdaptiveAgentOptions, maximumMs: number): number {
235
+ return recommendAttemptCutoff(profile, {
236
+ fallbackMs: options.policy.fallbackAttemptMs,
237
+ minimumMs: Math.min(options.policy.minimumAttemptMs, maximumMs),
238
+ maximumMs: Math.max(1, maximumMs),
239
+ });
240
+ }
@@ -0,0 +1,112 @@
1
+ import type { Message } from "omk-ai";
2
+ import {
3
+ type DeadlineOutcome,
4
+ emptyUsage,
5
+ type SingleResult,
6
+ type SubagentAttemptResult,
7
+ type UsageStats,
8
+ } from "./subagent-runtime-types.ts";
9
+
10
+ interface BaseResultOptions {
11
+ readonly agentName: string;
12
+ readonly agentSource: "user" | "project";
13
+ readonly logicalTask: string;
14
+ readonly model?: string;
15
+ readonly step?: number;
16
+ }
17
+
18
+ export function createBaseResult(options: BaseResultOptions): SingleResult {
19
+ return {
20
+ agent: options.agentName,
21
+ agentSource: options.agentSource,
22
+ task: options.logicalTask,
23
+ exitCode: 0,
24
+ messages: [],
25
+ stderr: "",
26
+ usage: emptyUsage(),
27
+ model: options.model,
28
+ step: options.step,
29
+ };
30
+ }
31
+
32
+ export function attemptOutcome(attempt: SubagentAttemptResult): Exclude<DeadlineOutcome, "budget-exhausted"> {
33
+ if (attempt.process.reason === "cutoff") return "cutoff";
34
+ if (attempt.process.reason === "aborted") return "aborted";
35
+ return attempt.result.exitCode === 0 && attempt.result.stopReason !== "error" ? "completed" : "failed";
36
+ }
37
+
38
+ export function mergeResult(target: SingleResult, source: SingleResult): void {
39
+ target.messages.push(...source.messages);
40
+ target.stderr = [target.stderr, source.stderr].filter(Boolean).join("\n");
41
+ target.usage = mergeUsage(target.usage, source.usage);
42
+ target.output = mergeOutput(target.output ?? "", resultOutput(source));
43
+ target.exitCode = source.exitCode;
44
+ target.stopReason = source.stopReason;
45
+ target.errorMessage = source.errorMessage;
46
+ target.model = source.model ?? target.model;
47
+ }
48
+
49
+ export function mergeForUpdate(base: SingleResult, partial: SingleResult): SingleResult {
50
+ const merged: SingleResult = { ...base, messages: [...base.messages], usage: { ...base.usage } };
51
+ mergeResult(merged, partial);
52
+ return merged;
53
+ }
54
+
55
+ export function resultOutput(result: SingleResult): string {
56
+ if (result.output !== undefined) return result.output;
57
+ for (let index = result.messages.length - 1; index >= 0; index--) {
58
+ const message: Message = result.messages[index];
59
+ if (message.role !== "assistant") continue;
60
+ for (const part of message.content) if (part.type === "text") return part.text;
61
+ }
62
+ return "";
63
+ }
64
+
65
+ export function finalizeResult(result: SingleResult, outcome: DeadlineOutcome): void {
66
+ if (outcome === "completed") {
67
+ result.exitCode = 0;
68
+ result.stopReason = "end";
69
+ result.errorMessage = undefined;
70
+ return;
71
+ }
72
+ if (outcome === "failed") {
73
+ result.exitCode = Math.max(1, result.exitCode);
74
+ result.stopReason = "error";
75
+ result.errorMessage ??= "Subagent process failed";
76
+ return;
77
+ }
78
+ result.exitCode = outcome === "aborted" ? 130 : 124;
79
+ result.stopReason = outcome === "aborted" ? "aborted" : "deadline";
80
+ result.errorMessage ??=
81
+ outcome === "budget-exhausted" ? "Subagent execution budget exhausted" : "Subagent deadline cutoff";
82
+ }
83
+
84
+ export function parseProviderModel(value: string | undefined): { provider: string; model: string } {
85
+ if (!value) return { provider: "unknown", model: "default" };
86
+ const separator = value.indexOf("/");
87
+ return separator < 1
88
+ ? { provider: "unknown", model: value }
89
+ : { provider: value.slice(0, separator), model: value.slice(separator + 1) || "default" };
90
+ }
91
+
92
+ function mergeUsage(left: UsageStats, right: UsageStats): UsageStats {
93
+ return {
94
+ input: left.input + right.input,
95
+ output: left.output + right.output,
96
+ cacheRead: left.cacheRead + right.cacheRead,
97
+ cacheWrite: left.cacheWrite + right.cacheWrite,
98
+ cost: left.cost + right.cost,
99
+ contextTokens: Math.max(left.contextTokens, right.contextTokens),
100
+ turns: left.turns + right.turns,
101
+ };
102
+ }
103
+
104
+ function mergeOutput(left: string, right: string): string {
105
+ if (right === "" || left === right || left.endsWith(right)) return left;
106
+ if (left === "") return right;
107
+ const maxOverlap = Math.min(left.length, right.length, 4_096);
108
+ for (let overlap = maxOverlap; overlap > 0; overlap--) {
109
+ if (left.endsWith(right.slice(0, overlap))) return `${left}${right.slice(overlap)}`;
110
+ }
111
+ return `${left}\n\n${right}`;
112
+ }
@@ -0,0 +1,125 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ buildCheckpointTask,
7
+ checkpointMadeProgress,
8
+ createCheckpointWorkspace,
9
+ readCheckpoint,
10
+ removeCheckpointWorkspace,
11
+ } from "./checkpoint-runtime.ts";
12
+
13
+ const cleanupPaths: string[] = [];
14
+
15
+ afterEach(async () => {
16
+ await Promise.all(cleanupPaths.splice(0).map((entry) => fs.promises.rm(entry, { force: true, recursive: true })));
17
+ });
18
+
19
+ describe("subagent checkpoint runtime", () => {
20
+ it("loads a bounded, typed checkpoint written by a child", async () => {
21
+ const workspace = await createCheckpointWorkspace(path.join(os.tmpdir(), "omk-checkpoint-test-"));
22
+ cleanupPaths.push(workspace.dir);
23
+ await fs.promises.writeFile(
24
+ workspace.filePath,
25
+ JSON.stringify({
26
+ version: 1,
27
+ completed: ["inspect boundary"],
28
+ remaining: ["add tests"],
29
+ summary: "boundary inspected",
30
+ artifacts: ["src/runtime.ts"],
31
+ }),
32
+ "utf8",
33
+ );
34
+
35
+ const checkpoint = await readCheckpoint(workspace.filePath, {
36
+ messageCount: 3,
37
+ outputTail: "stream tail",
38
+ lastEventAtMs: 123,
39
+ });
40
+
41
+ expect(checkpoint).toMatchObject({
42
+ completed: ["inspect boundary"],
43
+ remaining: ["add tests"],
44
+ summary: "boundary inspected",
45
+ artifacts: ["src/runtime.ts"],
46
+ messageCount: 3,
47
+ outputTail: "stream tail",
48
+ lastEventAtMs: 123,
49
+ });
50
+ });
51
+
52
+ it("rejects a checkpoint path replaced with a symbolic link", async () => {
53
+ const workspace = await createCheckpointWorkspace(path.join(os.tmpdir(), "omk-checkpoint-symlink-"));
54
+ cleanupPaths.push(workspace.dir);
55
+ const targetPath = path.join(workspace.dir, "target.json");
56
+ await fs.promises.writeFile(
57
+ targetPath,
58
+ JSON.stringify({ version: 1, completed: ["untrusted target"], remaining: [], summary: "leak", artifacts: [] }),
59
+ "utf8",
60
+ );
61
+ await fs.promises.rm(workspace.filePath);
62
+ await fs.promises.symlink(targetPath, workspace.filePath);
63
+
64
+ const checkpoint = await readCheckpoint(workspace.filePath, {
65
+ messageCount: 0,
66
+ outputTail: "",
67
+ lastEventAtMs: 0,
68
+ });
69
+
70
+ expect(checkpoint.completed).toEqual([]);
71
+ expect(checkpoint.summary).toBe("");
72
+ });
73
+
74
+ it("builds a bounded resume prompt that forbids replaying completed side effects", () => {
75
+ const hugeTail = `old-output-${"x".repeat(20_000)}`;
76
+ const prompt = buildCheckpointTask({
77
+ originalTask: "Implement the deadline runtime.",
78
+ shardTask: "Add the process cleanup path.",
79
+ shardId: "shard-2",
80
+ shardIndex: 1,
81
+ shardCount: 3,
82
+ attempt: 2,
83
+ cutoffMs: 30_000,
84
+ checkpointFilePath: "/tmp/checkpoint.json",
85
+ checkpoint: {
86
+ version: 1,
87
+ completed: ["sent SIGTERM"],
88
+ remaining: ["verify SIGKILL"],
89
+ summary: "graceful termination implemented",
90
+ artifacts: ["process-runtime.ts"],
91
+ messageCount: 4,
92
+ outputTail: hugeTail,
93
+ lastEventAtMs: 100,
94
+ },
95
+ });
96
+
97
+ expect(prompt).toContain("Do not repeat completed side effects");
98
+ expect(prompt).toContain("sent SIGTERM");
99
+ expect(prompt).toContain("verify SIGKILL");
100
+ expect(prompt).toContain("Only unresolved checkpoint units");
101
+ expect(prompt).not.toContain("Add the process cleanup path.");
102
+ expect(prompt.length).toBeLessThan(12_000);
103
+ });
104
+
105
+ it("blocks duplicate resume when neither checkpoint nor streamed evidence advanced", () => {
106
+ const before = {
107
+ version: 1 as const,
108
+ completed: ["a"],
109
+ remaining: ["b"],
110
+ summary: "same",
111
+ artifacts: [],
112
+ messageCount: 2,
113
+ outputTail: "same tail",
114
+ lastEventAtMs: 10,
115
+ };
116
+ expect(checkpointMadeProgress(before, { ...before })).toBe(false);
117
+ expect(checkpointMadeProgress(before, { ...before, messageCount: 3 })).toBe(true);
118
+ });
119
+
120
+ it("removes checkpoint artifacts after the logical task settles", async () => {
121
+ const workspace = await createCheckpointWorkspace(path.join(os.tmpdir(), "omk-checkpoint-cleanup-"));
122
+ await removeCheckpointWorkspace(workspace);
123
+ expect(fs.existsSync(workspace.dir)).toBe(false);
124
+ });
125
+ });
@@ -0,0 +1,198 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface CheckpointWorkspace {
5
+ readonly dir: string;
6
+ readonly filePath: string;
7
+ }
8
+
9
+ export interface AgentCheckpoint {
10
+ readonly version: 1;
11
+ readonly completed: readonly string[];
12
+ readonly remaining: readonly string[];
13
+ readonly summary: string;
14
+ readonly artifacts: readonly string[];
15
+ readonly messageCount: number;
16
+ readonly outputTail: string;
17
+ readonly lastEventAtMs: number;
18
+ }
19
+
20
+ interface CheckpointEvidence {
21
+ readonly messageCount: number;
22
+ readonly outputTail: string;
23
+ readonly lastEventAtMs: number;
24
+ }
25
+
26
+ interface BuildCheckpointTaskOptions {
27
+ readonly originalTask: string;
28
+ readonly shardTask: string;
29
+ readonly shardId: string;
30
+ readonly shardIndex: number;
31
+ readonly shardCount: number;
32
+ readonly attempt: number;
33
+ readonly cutoffMs: number;
34
+ readonly checkpointFilePath: string;
35
+ readonly checkpoint?: AgentCheckpoint;
36
+ }
37
+
38
+ const CHECKPOINT_FILE_LIMIT = 32 * 1024;
39
+ const MAX_LIST_ITEMS = 24;
40
+ const MAX_ITEM_CHARS = 512;
41
+
42
+ export async function createCheckpointWorkspace(prefix: string): Promise<CheckpointWorkspace> {
43
+ const dir = await fs.promises.mkdtemp(prefix);
44
+ const filePath = path.join(dir, "checkpoint.json");
45
+ await writeCheckpoint(filePath, createEmptyCheckpoint());
46
+ return { dir, filePath };
47
+ }
48
+
49
+ export async function removeCheckpointWorkspace(workspace: CheckpointWorkspace): Promise<void> {
50
+ await fs.promises.rm(workspace.dir, { recursive: true, force: true });
51
+ }
52
+
53
+ export async function writeCheckpoint(filePath: string, checkpoint: AgentCheckpoint): Promise<void> {
54
+ const serialized = JSON.stringify({
55
+ version: 1,
56
+ completed: checkpoint.completed,
57
+ remaining: checkpoint.remaining,
58
+ summary: checkpoint.summary,
59
+ artifacts: checkpoint.artifacts,
60
+ });
61
+ await fs.promises.writeFile(filePath, serialized, { encoding: "utf8", mode: 0o600 });
62
+ }
63
+
64
+ export async function readCheckpoint(filePath: string, evidence: CheckpointEvidence): Promise<AgentCheckpoint> {
65
+ let parsed: unknown;
66
+ try {
67
+ const pathStats = await fs.promises.lstat(filePath);
68
+ if (!pathStats.isFile() || pathStats.isSymbolicLink()) return withEvidence(createEmptyCheckpoint(), evidence);
69
+ const handle = await fs.promises.open(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
70
+ try {
71
+ const stats = await handle.stat();
72
+ if (stats.size > CHECKPOINT_FILE_LIMIT) return withEvidence(createEmptyCheckpoint(), evidence);
73
+ parsed = JSON.parse(await handle.readFile("utf8"));
74
+ } finally {
75
+ await handle.close();
76
+ }
77
+ } catch {
78
+ return withEvidence(createEmptyCheckpoint(), evidence);
79
+ }
80
+ if (!isRecord(parsed) || parsed.version !== 1) return withEvidence(createEmptyCheckpoint(), evidence);
81
+ return withEvidence(
82
+ {
83
+ version: 1,
84
+ completed: readStringList(parsed.completed),
85
+ remaining: readStringList(parsed.remaining),
86
+ summary: readString(parsed.summary, 2_000),
87
+ artifacts: readStringList(parsed.artifacts),
88
+ messageCount: 0,
89
+ outputTail: "",
90
+ lastEventAtMs: 0,
91
+ },
92
+ evidence,
93
+ );
94
+ }
95
+
96
+ export function buildCheckpointTask(options: BuildCheckpointTaskOptions): string {
97
+ const prior = options.checkpoint;
98
+ const currentTask =
99
+ prior !== undefined && prior.remaining.length > 0
100
+ ? [
101
+ "Only unresolved checkpoint units:",
102
+ ...prior.remaining.map((unit, index) => `${index + 1}. ${boundedTail(unit, 512)}`),
103
+ ].join("\n")
104
+ : boundedTail(options.shardTask, 3_500);
105
+ const priorSection = prior
106
+ ? [
107
+ "Resume checkpoint:",
108
+ `- Completed: ${formatList(prior.completed)}`,
109
+ `- Remaining: ${formatList(prior.remaining)}`,
110
+ `- Summary: ${boundedTail(prior.summary, 1_500) || "(none)"}`,
111
+ `- Artifacts: ${formatList(prior.artifacts)}`,
112
+ `- Last streamed evidence: ${boundedTail(prior.outputTail, 2_500) || "(none)"}`,
113
+ ]
114
+ : ["No prior checkpoint exists for this shard."];
115
+ return [
116
+ "[OMK deadline/checkpoint execution contract]",
117
+ `Execution shard: ${options.shardId} (${options.shardIndex + 1}/${options.shardCount}), attempt ${options.attempt}.`,
118
+ `This attempt has about ${options.cutoffMs}ms. Work in small, complete semantic units.`,
119
+ `After each completed unit, overwrite ${options.checkpointFilePath} with JSON:`,
120
+ '{"version":1,"completed":["..."],"remaining":["..."],"summary":"...","artifacts":["..."]}',
121
+ "Only checkpoint work that is actually complete. Keep each field concise.",
122
+ "On resume, inspect the current workspace first. Do not repeat completed side effects or rerun expensive discovery already evidenced below.",
123
+ ...priorSection,
124
+ "",
125
+ "Original logical task (context only):",
126
+ boundedTail(options.originalTask, 2_500),
127
+ "",
128
+ "Current shard:",
129
+ currentTask,
130
+ "Return a self-contained result for this shard.",
131
+ ].join("\n");
132
+ }
133
+
134
+ export function checkpointMadeProgress(before: AgentCheckpoint | undefined, after: AgentCheckpoint): boolean {
135
+ if (before === undefined) {
136
+ return after.messageCount > 0 || after.completed.length > 0 || after.summary !== "" || after.outputTail !== "";
137
+ }
138
+ return (
139
+ after.messageCount > before.messageCount ||
140
+ after.outputTail !== before.outputTail ||
141
+ after.summary !== before.summary ||
142
+ !arrayEquals(after.completed, before.completed) ||
143
+ !arrayEquals(after.remaining, before.remaining) ||
144
+ !arrayEquals(after.artifacts, before.artifacts)
145
+ );
146
+ }
147
+
148
+ export function boundedTail(value: string, maxChars: number): string {
149
+ if (value.length <= maxChars) return value;
150
+ return `[...${value.length - maxChars} earlier chars omitted...]\n${value.slice(-maxChars)}`;
151
+ }
152
+
153
+ export function createEmptyCheckpoint(): AgentCheckpoint {
154
+ return {
155
+ version: 1,
156
+ completed: [],
157
+ remaining: [],
158
+ summary: "",
159
+ artifacts: [],
160
+ messageCount: 0,
161
+ outputTail: "",
162
+ lastEventAtMs: 0,
163
+ };
164
+ }
165
+
166
+ function withEvidence(checkpoint: AgentCheckpoint, evidence: CheckpointEvidence): AgentCheckpoint {
167
+ return {
168
+ ...checkpoint,
169
+ messageCount: Math.max(0, evidence.messageCount),
170
+ outputTail: boundedTail(evidence.outputTail, 4_000),
171
+ lastEventAtMs: Math.max(0, evidence.lastEventAtMs),
172
+ };
173
+ }
174
+
175
+ function readStringList(value: unknown): string[] {
176
+ if (!Array.isArray(value)) return [];
177
+ return value
178
+ .filter((item): item is string => typeof item === "string")
179
+ .map((item) => readString(item, MAX_ITEM_CHARS))
180
+ .filter((item) => item !== "")
181
+ .slice(0, MAX_LIST_ITEMS);
182
+ }
183
+
184
+ function readString(value: unknown, maxChars: number): string {
185
+ return typeof value === "string" ? value.trim().slice(0, maxChars) : "";
186
+ }
187
+
188
+ function formatList(values: readonly string[]): string {
189
+ return values.length === 0 ? "(none)" : values.map((value) => boundedTail(value, 256)).join("; ");
190
+ }
191
+
192
+ function isRecord(value: unknown): value is Record<string, unknown> {
193
+ return typeof value === "object" && value !== null && !Array.isArray(value);
194
+ }
195
+
196
+ function arrayEquals(left: readonly string[], right: readonly string[]): boolean {
197
+ return left.length === right.length && left.every((value, index) => value === right[index]);
198
+ }