@namzu/sdk 19.0.0 → 20.1.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/CHANGELOG.md +145 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +1 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/runAgent.d.ts +20 -0
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +2 -1
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/contracts/a2a.d.ts +2 -2
- package/dist/manager/run/persistence.d.ts +27 -0
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +39 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/public-runtime.d.ts +3 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +7 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +44 -9
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
- package/dist/runtime/query/plugin-hooks.js +1 -0
- package/dist/runtime/query/plugin-hooks.js.map +1 -1
- package/dist/store/index.d.ts +3 -0
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +12 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/run/checkpoint-disk.d.ts +56 -2
- package/dist/store/run/checkpoint-disk.d.ts.map +1 -1
- package/dist/store/run/checkpoint-disk.js +83 -2
- package/dist/store/run/checkpoint-disk.js.map +1 -1
- package/dist/store/run/checkpoint-memory.d.ts +31 -0
- package/dist/store/run/checkpoint-memory.d.ts.map +1 -0
- package/dist/store/run/checkpoint-memory.js +83 -0
- package/dist/store/run/checkpoint-memory.js.map +1 -0
- package/dist/store/run/disk.d.ts +53 -0
- package/dist/store/run/disk.d.ts.map +1 -1
- package/dist/store/run/disk.js +79 -30
- package/dist/store/run/disk.js.map +1 -1
- package/dist/store/run/listing.d.ts +75 -0
- package/dist/store/run/listing.d.ts.map +1 -0
- package/dist/store/run/listing.js +183 -0
- package/dist/store/run/listing.js.map +1 -0
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +20 -5
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +19 -2
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/agent/base.d.ts +20 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/plugin/index.d.ts +30 -1
- package/dist/types/plugin/index.d.ts.map +1 -1
- package/dist/types/plugin/index.js +1 -0
- package/dist/types/plugin/index.js.map +1 -1
- package/dist/types/run/checkpoint-store.d.ts +210 -1
- package/dist/types/run/checkpoint-store.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents/ReactiveAgent.ts +1 -0
- package/src/agents/runAgent.ts +24 -1
- package/src/manager/run/persistence.ts +46 -4
- package/src/public-runtime.ts +13 -0
- package/src/runtime/query/executor.ts +59 -11
- package/src/runtime/query/plugin-hooks.ts +1 -0
- package/src/store/index.ts +18 -0
- package/src/store/run/checkpoint-disk.ts +128 -4
- package/src/store/run/checkpoint-memory.ts +101 -0
- package/src/store/run/disk.ts +78 -27
- package/src/store/run/listing.ts +229 -0
- package/src/tools/coordinator/agent.ts +20 -5
- package/src/tools/coordinator/index.ts +22 -2
- package/src/types/agent/base.ts +20 -0
- package/src/types/plugin/index.ts +27 -1
- package/src/types/run/checkpoint-store.ts +221 -1
|
@@ -45,12 +45,26 @@ export class RunPersistence {
|
|
|
45
45
|
|
|
46
46
|
// Checkpoints go through the injectable seam; the disk layout under
|
|
47
47
|
// `outputDir` (same tree the runStore writes to) stays the default.
|
|
48
|
+
//
|
|
49
|
+
// The attribution is handed over because the layout does not record
|
|
50
|
+
// it — there is no tenant segment in the path at all — and without it
|
|
51
|
+
// the default store can persist checkpoints but cannot ENUMERATE
|
|
52
|
+
// them, which is the state the contract just stopped being in. A
|
|
53
|
+
// listing capability the default store declines is a capability no
|
|
54
|
+
// host reaches.
|
|
48
55
|
this.checkpointStore =
|
|
49
56
|
config.checkpointStore ??
|
|
50
|
-
new DiskCheckpointStore(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
new DiskCheckpointStore(
|
|
58
|
+
{
|
|
59
|
+
baseDir: config.outputDir,
|
|
60
|
+
logger: config.log,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
tenantId: config.tenantId,
|
|
64
|
+
projectId: config.projectId,
|
|
65
|
+
sessionId: config.sessionId,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
54
68
|
|
|
55
69
|
this.run = {
|
|
56
70
|
id: config.runId,
|
|
@@ -266,8 +280,36 @@ export class RunPersistence {
|
|
|
266
280
|
this.resultOverridden = true
|
|
267
281
|
}
|
|
268
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Record the schema-validated answer, and make `result` agree with it.
|
|
285
|
+
*
|
|
286
|
+
* `result` used to be left alone here, and the consequence was not "empty"
|
|
287
|
+
* but "wrong": `resolveResult` walks back from the message tail and stops at
|
|
288
|
+
* the first non-assistant message, so a structured run — whose last
|
|
289
|
+
* assistant turn is a tool call, not prose — kept whatever text an EARLIER
|
|
290
|
+
* turn happened to produce. A host reading `run.result` got a sentence from
|
|
291
|
+
* the middle of the run presented as its answer.
|
|
292
|
+
*
|
|
293
|
+
* Three options, and the other two are worse:
|
|
294
|
+
*
|
|
295
|
+
* - leave it: a stale value read as a fact, which is the defect;
|
|
296
|
+
* - clear it: a run that plainly answered reports no answer, so a host
|
|
297
|
+
* testing `if (run.result)` concludes nothing was produced;
|
|
298
|
+
* - serialize the structured value into it, which is what this does.
|
|
299
|
+
*
|
|
300
|
+
* The serialization is not an invention. Every text-shaped consumer — the
|
|
301
|
+
* transcript, `Run.result`, both delegation tools handing a child's answer
|
|
302
|
+
* back to a parent model — needs the answer as a string, and each of them
|
|
303
|
+
* would otherwise serialize it again, differently. One serialization, at the
|
|
304
|
+
* moment the value is known.
|
|
305
|
+
*
|
|
306
|
+
* Sticky, via `setResult`: `resolveResult` runs again when the run settles,
|
|
307
|
+
* and without the override flag it would walk the tail and put the stale
|
|
308
|
+
* prose back.
|
|
309
|
+
*/
|
|
269
310
|
setStructuredOutput(value: unknown): void {
|
|
270
311
|
this.run.structuredOutput = value
|
|
312
|
+
this.setResult(typeof value === 'string' ? value : JSON.stringify(value))
|
|
271
313
|
}
|
|
272
314
|
|
|
273
315
|
/**
|
package/src/public-runtime.ts
CHANGED
|
@@ -198,12 +198,25 @@ export {
|
|
|
198
198
|
DiskCheckpointStore,
|
|
199
199
|
DiskMemoryStore,
|
|
200
200
|
DiskTaskStore,
|
|
201
|
+
InMemoryCheckpointStore,
|
|
201
202
|
InMemoryMemoryIndex,
|
|
202
203
|
InMemoryMemoryStore,
|
|
203
204
|
InMemoryStore,
|
|
204
205
|
InMemoryTaskStore,
|
|
205
206
|
RunDiskStore,
|
|
206
207
|
} from './store/index.js'
|
|
208
|
+
export type { DiskCheckpointStoreAttribution } from './store/index.js'
|
|
209
|
+
// Enumerating runs above a run id — the read an approval inbox and a park
|
|
210
|
+
// sweep are built from, and the one the contract had no way to express.
|
|
211
|
+
// `listDurableRuns` REFUSES on a store that cannot list rather than
|
|
212
|
+
// reporting an empty page, because "nothing is waiting on a human" is not
|
|
213
|
+
// what "I cannot tell" means.
|
|
214
|
+
export {
|
|
215
|
+
assertContiguousListingScope,
|
|
216
|
+
listDurableRuns,
|
|
217
|
+
paginateDurableRuns,
|
|
218
|
+
toDurableRunEntry,
|
|
219
|
+
} from './store/index.js'
|
|
207
220
|
|
|
208
221
|
export {
|
|
209
222
|
AgentRegistry,
|
|
@@ -144,6 +144,20 @@ export interface ToolExecutorConfig {
|
|
|
144
144
|
repairToolCall?: RepairToolCall
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* What a `post_tool_use` hook decided to show the model instead.
|
|
149
|
+
*
|
|
150
|
+
* `isError` is the field this type exists for. The override used to be a bare
|
|
151
|
+
* string, so the executor had no way to tell "the call failed" from "the call
|
|
152
|
+
* succeeded and the model may not see all of it" — and it assumed the first,
|
|
153
|
+
* which turned every redaction into a reported tool failure.
|
|
154
|
+
*/
|
|
155
|
+
interface PostToolOverride {
|
|
156
|
+
readonly output: string
|
|
157
|
+
readonly isError: boolean
|
|
158
|
+
readonly content?: ToolResultContent
|
|
159
|
+
}
|
|
160
|
+
|
|
147
161
|
type PreToolHookOutcome =
|
|
148
162
|
| { kind: 'continue'; input: unknown }
|
|
149
163
|
| { kind: 'skip'; input: unknown; output: string }
|
|
@@ -708,10 +722,14 @@ export class ToolExecutor {
|
|
|
708
722
|
|
|
709
723
|
const postOverride = post.override
|
|
710
724
|
if (postOverride !== null) {
|
|
711
|
-
output = postOverride
|
|
725
|
+
output = postOverride.output
|
|
712
726
|
}
|
|
713
727
|
|
|
714
|
-
|
|
728
|
+
// A failed call, or an override that says the call failed. A `replace`
|
|
729
|
+
// says the opposite, and reading it as a failure is what made redaction
|
|
730
|
+
// unusable: the model was told a successful call had gone wrong, and
|
|
731
|
+
// routed around it.
|
|
732
|
+
const effectiveIsError = !result.success || (postOverride?.isError ?? false)
|
|
715
733
|
|
|
716
734
|
if (this.workingStateManager) {
|
|
717
735
|
extractFromToolResult(this.workingStateManager, toolName, output, effectiveIsError)
|
|
@@ -756,17 +774,31 @@ export class ToolExecutor {
|
|
|
756
774
|
...(budgeted.spillPath ? { outputSpillPath: budgeted.spillPath } : {}),
|
|
757
775
|
})
|
|
758
776
|
|
|
777
|
+
const resolveContent = (): { content?: ToolResultContent } => {
|
|
778
|
+
if (postOverride?.content !== undefined) {
|
|
779
|
+
return { content: this.budgetContent(postOverride.content, toolName) }
|
|
780
|
+
}
|
|
781
|
+
if (postOverride?.isError) return {}
|
|
782
|
+
if (budgeted.truncated || result.content === undefined) return {}
|
|
783
|
+
return { content: this.budgetContent(result.content, toolName) }
|
|
784
|
+
}
|
|
785
|
+
|
|
759
786
|
return {
|
|
760
787
|
toolCallId: toolCall.id,
|
|
761
788
|
toolName,
|
|
762
789
|
output,
|
|
763
790
|
isError: effectiveIsError,
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
791
|
+
// Rich content follows the override's own decision.
|
|
792
|
+
//
|
|
793
|
+
// An ERROR override drops it: the payload is no longer the tool's,
|
|
794
|
+
// and shipping an image beside a failure message describes something
|
|
795
|
+
// the model was just told did not happen. A spilled preview drops it
|
|
796
|
+
// for the same reason.
|
|
797
|
+
//
|
|
798
|
+
// A REPLACE keeps it, because the common case is redacting text from
|
|
799
|
+
// a result whose image is unaffected — and a hook that needs it gone
|
|
800
|
+
// says so with `content`, which wins over both.
|
|
801
|
+
...resolveContent(),
|
|
770
802
|
}
|
|
771
803
|
}
|
|
772
804
|
|
|
@@ -919,6 +951,11 @@ export class ToolExecutor {
|
|
|
919
951
|
output: `Error: ${result.message}`,
|
|
920
952
|
}
|
|
921
953
|
case 'retry':
|
|
954
|
+
// There is no result to replace yet. Rejecting loudly beats
|
|
955
|
+
// silently ignoring it: a hook author who returned this here
|
|
956
|
+
// meant to redact something and would otherwise watch the secret
|
|
957
|
+
// go through.
|
|
958
|
+
case 'replace':
|
|
922
959
|
throw new Error(
|
|
923
960
|
`Plugin hook pre_tool_use returned unsupported action '${result.action}' for tool ${toolName}`,
|
|
924
961
|
)
|
|
@@ -1120,21 +1157,32 @@ export class ToolExecutor {
|
|
|
1120
1157
|
toolName: string,
|
|
1121
1158
|
input: unknown,
|
|
1122
1159
|
toolResult: ToolResult,
|
|
1123
|
-
): Promise<{ override:
|
|
1160
|
+
): Promise<{ override: PostToolOverride | null; retry: boolean }> {
|
|
1124
1161
|
if (!this.config.pluginManager) return { override: null, retry: false }
|
|
1125
1162
|
const results = await this.config.pluginManager.executeHooks(
|
|
1126
1163
|
'post_tool_use',
|
|
1127
1164
|
{ runId: this.config.runId, toolName, toolInput: input, toolResult },
|
|
1128
1165
|
this.emitEvent,
|
|
1129
1166
|
)
|
|
1130
|
-
let override:
|
|
1167
|
+
let override: PostToolOverride | null = null
|
|
1131
1168
|
let retry = false
|
|
1132
1169
|
for (const result of results) {
|
|
1133
1170
|
switch (result.action) {
|
|
1134
1171
|
case 'continue':
|
|
1135
1172
|
continue
|
|
1136
1173
|
case 'error':
|
|
1137
|
-
override = `Error: ${result.message}
|
|
1174
|
+
override = { output: `Error: ${result.message}`, isError: true }
|
|
1175
|
+
continue
|
|
1176
|
+
// A redaction, not a failure. The call stood; the model is shown
|
|
1177
|
+
// less of it. Rich content survives unless the hook replaced it —
|
|
1178
|
+
// see the variant's own documentation for why that default, and
|
|
1179
|
+
// for what a hook redacting a secret in an image has to do.
|
|
1180
|
+
case 'replace':
|
|
1181
|
+
override = {
|
|
1182
|
+
output: result.output,
|
|
1183
|
+
isError: false,
|
|
1184
|
+
...(result.content !== undefined ? { content: result.content } : {}),
|
|
1185
|
+
}
|
|
1138
1186
|
continue
|
|
1139
1187
|
// `retry` was a declared variant with no implementation: every
|
|
1140
1188
|
// site that consumed it threw. Here it finally means something
|
package/src/store/index.ts
CHANGED
|
@@ -3,6 +3,24 @@ export type { Identifiable, Timestamped } from './InMemoryStore.js'
|
|
|
3
3
|
|
|
4
4
|
export { RunDiskStore } from './run/disk.js'
|
|
5
5
|
export { DiskCheckpointStore } from './run/checkpoint-disk.js'
|
|
6
|
+
export type { DiskCheckpointStoreAttribution } from './run/checkpoint-disk.js'
|
|
7
|
+
export { InMemoryCheckpointStore } from './run/checkpoint-memory.js'
|
|
8
|
+
// The refusing entry point to the optional listing capability, plus the two
|
|
9
|
+
// projections a host implementing its own backend actually calls: one turns
|
|
10
|
+
// a run's checkpoints into a row, the other applies the contract's filter,
|
|
11
|
+
// ordering and cursor. Re-deriving either is how two stores start
|
|
12
|
+
// disagreeing about what "outstanding" means or where a page ends.
|
|
13
|
+
//
|
|
14
|
+
// `summarizePark` and `DEFAULT_DURABLE_RUN_LIMIT` are deliberately NOT here.
|
|
15
|
+
// The first is an internal of `toDurableRunEntry` and no caller wants half a
|
|
16
|
+
// row; the second is a number a host reads by omitting `limit`. A name a
|
|
17
|
+
// host has no use for is surface to keep correct forever for nobody.
|
|
18
|
+
export {
|
|
19
|
+
assertContiguousListingScope,
|
|
20
|
+
listDurableRuns,
|
|
21
|
+
paginateDurableRuns,
|
|
22
|
+
toDurableRunEntry,
|
|
23
|
+
} from './run/listing.js'
|
|
6
24
|
|
|
7
25
|
export { ActivityStore } from './activity/memory.js'
|
|
8
26
|
export type { ActivityEvent, ActivityEventListener } from './activity/memory.js'
|
|
@@ -1,8 +1,40 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { NamzuError } from '../../types/errors/index.js'
|
|
1
4
|
import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
|
|
2
|
-
import type { RunId } from '../../types/ids/index.js'
|
|
3
|
-
import type {
|
|
5
|
+
import type { RunId, SessionId, TenantId } from '../../types/ids/index.js'
|
|
6
|
+
import type {
|
|
7
|
+
CheckpointListingScope,
|
|
8
|
+
CheckpointRunScope,
|
|
9
|
+
CheckpointStore,
|
|
10
|
+
DurableRunEntry,
|
|
11
|
+
DurableRunPage,
|
|
12
|
+
ListDurableRunsOptions,
|
|
13
|
+
} from '../../types/run/checkpoint-store.js'
|
|
4
14
|
import type { RunStoreConfig } from '../../types/run/index.js'
|
|
5
|
-
import {
|
|
15
|
+
import type { ProjectId } from '../../types/session/ids.js'
|
|
16
|
+
import { RunDiskStore, readCheckpointsIn } from './disk.js'
|
|
17
|
+
import { assertContiguousListingScope, paginateDurableRuns, toDurableRunEntry } from './listing.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The attribution a disk store's own layout does not record.
|
|
21
|
+
*
|
|
22
|
+
* The canonical layout is
|
|
23
|
+
* `{root}/projects/{projectId}/sessions/{sessionId}/runs/{runId}` — there is
|
|
24
|
+
* no tenant segment anywhere in it, and `baseDir` is already one session's
|
|
25
|
+
* `runs/` directory, so the project and session are implicit in a string the
|
|
26
|
+
* store cannot parse back out without knowing the layout that built it.
|
|
27
|
+
*
|
|
28
|
+
* A per-run read never needed any of it: the caller supplies a full
|
|
29
|
+
* `CheckpointRunScope` and the store only uses `runId`. A LISTING does — its
|
|
30
|
+
* rows have to be addressable, and a row with no tenant is a row nothing can
|
|
31
|
+
* be resumed from. So the store is told, once, what tree it is holding.
|
|
32
|
+
*/
|
|
33
|
+
export interface DiskCheckpointStoreAttribution {
|
|
34
|
+
readonly tenantId: TenantId
|
|
35
|
+
readonly projectId: ProjectId
|
|
36
|
+
readonly sessionId: SessionId
|
|
37
|
+
}
|
|
6
38
|
|
|
7
39
|
/**
|
|
8
40
|
* Disk conformance layer for {@link CheckpointStore}: adapts the existing
|
|
@@ -20,10 +52,20 @@ import { RunDiskStore } from './disk.js'
|
|
|
20
52
|
*/
|
|
21
53
|
export class DiskCheckpointStore implements CheckpointStore {
|
|
22
54
|
private readonly config: RunStoreConfig
|
|
55
|
+
private readonly attribution?: DiskCheckpointStoreAttribution
|
|
23
56
|
private readonly bound = new Map<RunId, Promise<RunDiskStore>>()
|
|
24
57
|
|
|
25
|
-
|
|
58
|
+
/**
|
|
59
|
+
* @param config the run-store config; `baseDir` is one session's `runs/`
|
|
60
|
+
* directory.
|
|
61
|
+
* @param attribution what tree this is, for
|
|
62
|
+
* {@link DiskCheckpointStore.listDurableRuns}. Optional so that adding
|
|
63
|
+
* the listing did not change an existing construction; a store built
|
|
64
|
+
* without it refuses to list rather than inventing a tenant.
|
|
65
|
+
*/
|
|
66
|
+
constructor(config: RunStoreConfig, attribution?: DiskCheckpointStoreAttribution) {
|
|
26
67
|
this.config = config
|
|
68
|
+
this.attribution = attribution
|
|
27
69
|
}
|
|
28
70
|
|
|
29
71
|
private bind(scope: CheckpointRunScope): Promise<RunDiskStore> {
|
|
@@ -64,4 +106,86 @@ export class DiskCheckpointStore implements CheckpointStore {
|
|
|
64
106
|
const store = await this.bind(scope)
|
|
65
107
|
await store.deleteCheckpoint(checkpointId)
|
|
66
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every run with checkpoints under this store's tree.
|
|
112
|
+
*
|
|
113
|
+
* Reads the directories rather than binding a {@link RunDiskStore} per
|
|
114
|
+
* run, because binding CREATES the run directory — a listing that
|
|
115
|
+
* materialized a directory for every run it looked at would grow the tree
|
|
116
|
+
* it is reporting on.
|
|
117
|
+
*
|
|
118
|
+
* ### Why a two-level walk reaches every depth
|
|
119
|
+
*
|
|
120
|
+
* `initRun` nests exactly one level: a run with a parent goes to
|
|
121
|
+
* `{baseDir}/{parentRunId}/children/{runId}`, and a grandchild goes to
|
|
122
|
+
* `{baseDir}/{itsOwnParentRunId}/children/{runId}` — beside the top-level
|
|
123
|
+
* runs, not beneath its grandparent. So the tree is flat-with-one-nesting
|
|
124
|
+
* at every depth, `{baseDir}/*` plus `{baseDir}/* /children/*` enumerates
|
|
125
|
+
* all of it, and each run's `parentRunId` is the directory it sits under.
|
|
126
|
+
* A deep run leaves a bare shell directory under its own id at the top
|
|
127
|
+
* level (`{baseDir}/{parentRunId}` created by `mkdir -p` for a child of a
|
|
128
|
+
* run whose own data lives elsewhere); those hold no `checkpoints/` and
|
|
129
|
+
* drop out as entries with no durable state.
|
|
130
|
+
*/
|
|
131
|
+
async listDurableRuns(
|
|
132
|
+
scope: CheckpointListingScope,
|
|
133
|
+
options?: ListDurableRunsOptions,
|
|
134
|
+
): Promise<DurableRunPage> {
|
|
135
|
+
assertContiguousListingScope(scope, 'DiskCheckpointStore.listDurableRuns')
|
|
136
|
+
|
|
137
|
+
const attribution = this.attribution
|
|
138
|
+
if (!attribution) {
|
|
139
|
+
throw new NamzuError({
|
|
140
|
+
code: 'invalid_config',
|
|
141
|
+
message:
|
|
142
|
+
'DiskCheckpointStore.listDurableRuns: this store was constructed without attribution, so it cannot say which tenant, project or session its runs belong to — and a listing row that carries no scope is a row nothing can be resumed or swept from. Pass the second constructor argument. Refusing rather than returning rows stamped with a guessed tenant.',
|
|
143
|
+
details: { baseDir: this.config.baseDir },
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A listing is scoped, not addressed: a query for another tenant is a
|
|
148
|
+
// question this tree has no rows for, not an isolation violation. Same
|
|
149
|
+
// reasoning `SessionStore.listSessions` already states for sessions
|
|
150
|
+
// that happen to share a thread id across tenants.
|
|
151
|
+
if (
|
|
152
|
+
scope.tenantId !== attribution.tenantId ||
|
|
153
|
+
(scope.projectId !== undefined && scope.projectId !== attribution.projectId) ||
|
|
154
|
+
(scope.sessionId !== undefined && scope.sessionId !== attribution.sessionId)
|
|
155
|
+
) {
|
|
156
|
+
return { entries: [] }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const now = options?.now ?? Date.now()
|
|
160
|
+
const entries: DurableRunEntry[] = []
|
|
161
|
+
|
|
162
|
+
for (const runId of await this.readRunDirs(this.config.baseDir)) {
|
|
163
|
+
const runDir = join(this.config.baseDir, runId)
|
|
164
|
+
|
|
165
|
+
const own = toDurableRunEntry({ ...attribution, runId }, await readCheckpointsIn(runDir), now)
|
|
166
|
+
if (own) entries.push(own)
|
|
167
|
+
|
|
168
|
+
for (const childId of await this.readRunDirs(join(runDir, 'children'))) {
|
|
169
|
+
const child = toDurableRunEntry(
|
|
170
|
+
{ ...attribution, runId: childId, parentRunId: runId },
|
|
171
|
+
await readCheckpointsIn(join(runDir, 'children', childId)),
|
|
172
|
+
now,
|
|
173
|
+
)
|
|
174
|
+
if (child) entries.push(child)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return paginateDurableRuns(entries, options)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Directory names under `dir`, or none when `dir` does not exist. */
|
|
182
|
+
private async readRunDirs(dir: string): Promise<RunId[]> {
|
|
183
|
+
try {
|
|
184
|
+
const found = await readdir(dir, { withFileTypes: true })
|
|
185
|
+
return found.filter((e) => e.isDirectory()).map((e) => e.name as RunId)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
188
|
+
throw err
|
|
189
|
+
}
|
|
190
|
+
}
|
|
67
191
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
|
|
2
|
+
import type {
|
|
3
|
+
CheckpointListingScope,
|
|
4
|
+
CheckpointRunScope,
|
|
5
|
+
CheckpointStore,
|
|
6
|
+
DurableRunEntry,
|
|
7
|
+
DurableRunPage,
|
|
8
|
+
ListDurableRunsOptions,
|
|
9
|
+
} from '../../types/run/checkpoint-store.js'
|
|
10
|
+
import { assertContiguousListingScope, paginateDurableRuns, toDurableRunEntry } from './listing.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Process-local {@link CheckpointStore}, keyed by the full five-layer scope.
|
|
14
|
+
*
|
|
15
|
+
* Shipped rather than left as a test fixture for two reasons. It is the
|
|
16
|
+
* reference a host reads when writing a backend of its own — the disk store
|
|
17
|
+
* is path-addressed and answers "what does an attribution-keyed store look
|
|
18
|
+
* like" with a directory layout, which is the wrong lesson. And it is the
|
|
19
|
+
* only implementation that can hold more than one tenant at once, because
|
|
20
|
+
* the disk layout has no tenant in it: a test that two tenants' listings
|
|
21
|
+
* stay separate is not expressible against disk, and a rule that cannot be
|
|
22
|
+
* tested on the store a host will actually inject is a rule on paper.
|
|
23
|
+
*
|
|
24
|
+
* Not durable, deliberately: it is for tests, for a single-process host that
|
|
25
|
+
* genuinely wants checkpoints to die with the process, and as the parity
|
|
26
|
+
* partner that proves the listing contract is not a filesystem in disguise.
|
|
27
|
+
*/
|
|
28
|
+
export class InMemoryCheckpointStore implements CheckpointStore {
|
|
29
|
+
/** `tenant/project/session/run` → checkpoint id → checkpoint. */
|
|
30
|
+
private readonly runs = new Map<string, Map<CheckpointId, IterationCheckpoint>>()
|
|
31
|
+
/** Same key → the run's scope, so a listing can rebuild an addressable entry. */
|
|
32
|
+
private readonly scopes = new Map<string, CheckpointRunScope>()
|
|
33
|
+
|
|
34
|
+
private key(scope: CheckpointRunScope): string {
|
|
35
|
+
return [scope.tenantId, scope.projectId, scope.sessionId, scope.runId].join('/')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async writeCheckpoint(scope: CheckpointRunScope, checkpoint: IterationCheckpoint): Promise<void> {
|
|
39
|
+
const key = this.key(scope)
|
|
40
|
+
let run = this.runs.get(key)
|
|
41
|
+
if (!run) {
|
|
42
|
+
run = new Map()
|
|
43
|
+
this.runs.set(key, run)
|
|
44
|
+
}
|
|
45
|
+
// The run's scope is kept beside its checkpoints because the key is a
|
|
46
|
+
// joined string and a listing has to hand back the parts — above all
|
|
47
|
+
// `parentRunId`, which is what makes a sub-run's row addressable.
|
|
48
|
+
//
|
|
49
|
+
// Written on every call rather than only the first, and that is
|
|
50
|
+
// simplicity, not defence: a run's scope is fixed when the run is
|
|
51
|
+
// constructed, so the two cannot differ, and a `has` guard here would
|
|
52
|
+
// be a branch no input can take.
|
|
53
|
+
this.scopes.set(key, {
|
|
54
|
+
tenantId: scope.tenantId,
|
|
55
|
+
projectId: scope.projectId,
|
|
56
|
+
sessionId: scope.sessionId,
|
|
57
|
+
runId: scope.runId,
|
|
58
|
+
...(scope.parentRunId ? { parentRunId: scope.parentRunId } : {}),
|
|
59
|
+
})
|
|
60
|
+
run.set(checkpoint.id, checkpoint)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async readCheckpoint(
|
|
64
|
+
scope: CheckpointRunScope,
|
|
65
|
+
checkpointId: CheckpointId,
|
|
66
|
+
): Promise<IterationCheckpoint | null> {
|
|
67
|
+
return this.runs.get(this.key(scope))?.get(checkpointId) ?? null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async listCheckpoints(scope: CheckpointRunScope): Promise<IterationCheckpoint[]> {
|
|
71
|
+
const run = this.runs.get(this.key(scope))
|
|
72
|
+
if (!run) return []
|
|
73
|
+
return [...run.values()].sort((a, b) => a.createdAt - b.createdAt)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void> {
|
|
77
|
+
this.runs.get(this.key(scope))?.delete(checkpointId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async listDurableRuns(
|
|
81
|
+
scope: CheckpointListingScope,
|
|
82
|
+
options?: ListDurableRunsOptions,
|
|
83
|
+
): Promise<DurableRunPage> {
|
|
84
|
+
assertContiguousListingScope(scope, 'InMemoryCheckpointStore.listDurableRuns')
|
|
85
|
+
const now = options?.now ?? Date.now()
|
|
86
|
+
|
|
87
|
+
const entries: DurableRunEntry[] = []
|
|
88
|
+
for (const [key, checkpoints] of this.runs) {
|
|
89
|
+
const runScope = this.scopes.get(key)
|
|
90
|
+
if (!runScope) continue
|
|
91
|
+
if (runScope.tenantId !== scope.tenantId) continue
|
|
92
|
+
if (scope.projectId !== undefined && runScope.projectId !== scope.projectId) continue
|
|
93
|
+
if (scope.sessionId !== undefined && runScope.sessionId !== scope.sessionId) continue
|
|
94
|
+
|
|
95
|
+
const entry = toDurableRunEntry(runScope, [...checkpoints.values()], now)
|
|
96
|
+
if (entry) entries.push(entry)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return paginateDurableRuns(entries, options)
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/store/run/disk.ts
CHANGED
|
@@ -136,6 +136,12 @@ export class RunDiskStore {
|
|
|
136
136
|
messageCount: run.messages.length,
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// The schema-validated answer belongs in the durable record for the same
|
|
140
|
+
// reason `result` does: a run reloaded by id that has lost its answer has
|
|
141
|
+
// lost the thing it was run for. Written only when present, so a run that
|
|
142
|
+
// asked for no schema carries no key rather than an explicit `undefined`.
|
|
143
|
+
if (run.structuredOutput !== undefined) meta.structuredOutput = run.structuredOutput
|
|
144
|
+
|
|
139
145
|
if (run.parentRunId) meta.parentRunId = run.parentRunId
|
|
140
146
|
if (run.depth !== undefined && run.depth > 0) meta.depth = run.depth
|
|
141
147
|
|
|
@@ -186,33 +192,7 @@ export class RunDiskStore {
|
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
async listCheckpoints(): Promise<IterationCheckpoint[]> {
|
|
189
|
-
|
|
190
|
-
const cpDir = join(dir, 'checkpoints')
|
|
191
|
-
try {
|
|
192
|
-
const files = await readdir(cpDir)
|
|
193
|
-
const checkpoints: IterationCheckpoint[] = []
|
|
194
|
-
for (const file of files) {
|
|
195
|
-
if (!file.endsWith('.json')) continue
|
|
196
|
-
// An unreadable checkpoint used to be logged and skipped, so
|
|
197
|
-
// this returned a silently short list that four callers treat
|
|
198
|
-
// as complete. A missing NEWEST checkpoint quietly resumes
|
|
199
|
-
// from an older point and re-runs a whole iteration of tool
|
|
200
|
-
// calls; a missing PARKED one reports "not parked" and drops
|
|
201
|
-
// an approval a human already granted, because the file is
|
|
202
|
-
// the only durable record of a park. Pruning under-deletes
|
|
203
|
-
// too: a file the keep-count cannot see is immortal.
|
|
204
|
-
//
|
|
205
|
-
// The by-id read next door was already strict. Two read paths
|
|
206
|
-
// disagreeing about whether damage matters is how the lenient
|
|
207
|
-
// one gets trusted.
|
|
208
|
-
const content = await readFile(join(cpDir, file), 'utf-8')
|
|
209
|
-
checkpoints.push(parseCheckpoint(content, file))
|
|
210
|
-
}
|
|
211
|
-
return checkpoints.sort((a, b) => a.createdAt - b.createdAt)
|
|
212
|
-
} catch (err) {
|
|
213
|
-
if (isFileNotFound(err)) return []
|
|
214
|
-
throw err
|
|
215
|
-
}
|
|
195
|
+
return readCheckpointsIn(this.requireInit())
|
|
216
196
|
}
|
|
217
197
|
|
|
218
198
|
async deleteCheckpoint(checkpointId: CheckpointId): Promise<void> {
|
|
@@ -224,6 +204,29 @@ export class RunDiskStore {
|
|
|
224
204
|
}
|
|
225
205
|
}
|
|
226
206
|
|
|
207
|
+
/**
|
|
208
|
+
* @deprecated Superseded by
|
|
209
|
+
* {@link import('../../types/run/checkpoint-store.js').CheckpointStore.listDurableRuns},
|
|
210
|
+
* reached through {@link import('./listing.js').listDurableRuns}.
|
|
211
|
+
* Removed in the next major.
|
|
212
|
+
*
|
|
213
|
+
* Three things are wrong with `index.json` as the answer to "which runs
|
|
214
|
+
* are there":
|
|
215
|
+
*
|
|
216
|
+
* 1. Its entries carry no tenant, project or session, so a row cannot be
|
|
217
|
+
* turned back into an addressable scope — nothing can be resumed or
|
|
218
|
+
* swept from it.
|
|
219
|
+
* 2. `addToIndex` skips every sub-run, so an inbox built on it drops
|
|
220
|
+
* every approval raised by delegated work, and the symptom looks like
|
|
221
|
+
* a hung specialist rather than a blind listing.
|
|
222
|
+
* 3. It is a catalogue of runs that STARTED, not of runs with durable
|
|
223
|
+
* state, so it cannot tell a run something could resume from one that
|
|
224
|
+
* left nothing behind.
|
|
225
|
+
*
|
|
226
|
+
* Deprecated for one minor rather than deleted outright: this is public
|
|
227
|
+
* surface and a consumer calling it today gets real data back, so the
|
|
228
|
+
* deprecate-before-you-remove rule applies.
|
|
229
|
+
*/
|
|
227
230
|
static async listRuns(baseDir: string): Promise<
|
|
228
231
|
Array<{
|
|
229
232
|
id: string
|
|
@@ -291,6 +294,54 @@ export class RunDiskStore {
|
|
|
291
294
|
}
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Every checkpoint stored under one run directory, ascending by `createdAt`.
|
|
299
|
+
*
|
|
300
|
+
* A free function rather than a method because the scope-level listing walks
|
|
301
|
+
* run directories it has never bound a {@link RunDiskStore} to — and binding
|
|
302
|
+
* one would CREATE the directory, which is not something a read should do.
|
|
303
|
+
* Sharing the function is what keeps the two read paths from disagreeing
|
|
304
|
+
* about what a damaged file means.
|
|
305
|
+
*
|
|
306
|
+
* An unreadable checkpoint used to be logged and skipped, so this returned a
|
|
307
|
+
* silently short list that four callers treat as complete. A missing NEWEST
|
|
308
|
+
* checkpoint quietly resumes from an older point and re-runs a whole
|
|
309
|
+
* iteration of tool calls; a missing PARKED one reports "not parked" and
|
|
310
|
+
* drops an approval a human already granted, because the file is the only
|
|
311
|
+
* durable record of a park. Pruning under-deletes too: a file the keep-count
|
|
312
|
+
* cannot see is immortal. The by-id read next door was already strict, and
|
|
313
|
+
* two read paths disagreeing about whether damage matters is how the lenient
|
|
314
|
+
* one gets trusted.
|
|
315
|
+
*
|
|
316
|
+
* The same reasoning carries up to the listing, which is why the throw
|
|
317
|
+
* propagates there rather than dropping the run: a damaged checkpoint that
|
|
318
|
+
* removed a run from an approval inbox is the missing-park failure again,
|
|
319
|
+
* one level up.
|
|
320
|
+
*
|
|
321
|
+
* A missing `checkpoints/` directory is the only absence that reads as
|
|
322
|
+
* empty — the run genuinely has none. A file that disappears BETWEEN the
|
|
323
|
+
* directory listing and its read throws, where the old shape returned the
|
|
324
|
+
* empty array and discarded every checkpoint it had already parsed.
|
|
325
|
+
*/
|
|
326
|
+
export async function readCheckpointsIn(runDir: string): Promise<IterationCheckpoint[]> {
|
|
327
|
+
const cpDir = join(runDir, 'checkpoints')
|
|
328
|
+
let files: string[]
|
|
329
|
+
try {
|
|
330
|
+
files = await readdir(cpDir)
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (isFileNotFound(err)) return []
|
|
333
|
+
throw err
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const checkpoints: IterationCheckpoint[] = []
|
|
337
|
+
for (const file of files) {
|
|
338
|
+
if (!file.endsWith('.json')) continue
|
|
339
|
+
const content = await readFile(join(cpDir, file), 'utf-8')
|
|
340
|
+
checkpoints.push(parseCheckpoint(content, file))
|
|
341
|
+
}
|
|
342
|
+
return checkpoints.sort((a, b) => a.createdAt - b.createdAt)
|
|
343
|
+
}
|
|
344
|
+
|
|
294
345
|
async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
|
|
295
346
|
await atomicWriteFile(filePath, JSON.stringify(stamp(SCHEMA, value), null, 2))
|
|
296
347
|
}
|