@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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared machinery for {@link CheckpointStore.listDurableRuns}.
|
|
3
|
+
*
|
|
4
|
+
* Every rule the listing promises — the contiguous-prefix refusal, the park
|
|
5
|
+
* precedence, the ordering, the paging — lives here and is used by BOTH
|
|
6
|
+
* shipped implementations. A rule implemented twice is a rule that holds in
|
|
7
|
+
* one store and not the other, and the whole point of a store contract is
|
|
8
|
+
* that a host can swap the backend without swapping the semantics.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { NamzuError } from '../../types/errors/index.js'
|
|
12
|
+
import type { IterationCheckpoint, PendingDecision } from '../../types/hitl/index.js'
|
|
13
|
+
import type {
|
|
14
|
+
CheckpointListingScope,
|
|
15
|
+
CheckpointRunScope,
|
|
16
|
+
CheckpointStore,
|
|
17
|
+
DurableRunEntry,
|
|
18
|
+
DurableRunPage,
|
|
19
|
+
ListDurableRunsOptions,
|
|
20
|
+
ParkState,
|
|
21
|
+
ParkSummary,
|
|
22
|
+
} from '../../types/run/checkpoint-store.js'
|
|
23
|
+
|
|
24
|
+
/** Page size when the caller names none. */
|
|
25
|
+
export const DEFAULT_DURABLE_RUN_LIMIT = 100
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Refuse a listing scope with a hole in it.
|
|
29
|
+
*
|
|
30
|
+
* `{ tenantId, sessionId }` reads as "that session under whichever project
|
|
31
|
+
* holds it". A flat backend can answer it; a hierarchical one cannot look up
|
|
32
|
+
* a session without its project. Answering differently per backend is the
|
|
33
|
+
* one thing the contract exists to prevent, so neither answers: the caller
|
|
34
|
+
* names the project it means.
|
|
35
|
+
*/
|
|
36
|
+
export function assertContiguousListingScope(scope: CheckpointListingScope, caller: string): void {
|
|
37
|
+
if (scope.sessionId !== undefined && scope.projectId === undefined) {
|
|
38
|
+
throw new NamzuError({
|
|
39
|
+
code: 'invalid_config',
|
|
40
|
+
message: `${caller}: listing scope has a hole — \`sessionId\` was supplied without \`projectId\`. A run listing scope is a contiguous prefix of tenant → project → session; name the project the session belongs to.`,
|
|
41
|
+
details: { tenantId: scope.tenantId, sessionId: scope.sessionId },
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether a park's absolute deadline has passed. No deadline never expires. */
|
|
47
|
+
function isPastDeadline(pending: PendingDecision, now: number): boolean {
|
|
48
|
+
return pending.deadlineAt !== undefined && now >= pending.deadlineAt
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Which of the three states a recorded park is in.
|
|
53
|
+
*
|
|
54
|
+
* `resolved` is checked FIRST: a park that was answered after its deadline
|
|
55
|
+
* passed is answered, not expired. Reading the deadline first would report
|
|
56
|
+
* a decision a human actually made as an expiry nobody made, and the
|
|
57
|
+
* checkpoint is the evidence record for exactly that question.
|
|
58
|
+
*/
|
|
59
|
+
function parkStateOf(pending: PendingDecision, now: number): ParkState {
|
|
60
|
+
if (pending.resolvedAt !== undefined) return 'resolved'
|
|
61
|
+
return isPastDeadline(pending, now) ? 'expired' : 'outstanding'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toParkSummary(
|
|
65
|
+
cp: IterationCheckpoint,
|
|
66
|
+
pending: PendingDecision,
|
|
67
|
+
now: number,
|
|
68
|
+
): ParkSummary {
|
|
69
|
+
return {
|
|
70
|
+
state: parkStateOf(pending, now),
|
|
71
|
+
checkpointId: cp.id,
|
|
72
|
+
requestType: pending.request.type,
|
|
73
|
+
parkedAt: pending.parkedAt,
|
|
74
|
+
...(pending.deadlineAt !== undefined ? { deadlineAt: pending.deadlineAt } : {}),
|
|
75
|
+
...(pending.resolvedAt !== undefined ? { resolvedAt: pending.resolvedAt } : {}),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The one park that describes what a run is doing now, out of every park it
|
|
81
|
+
* ever recorded.
|
|
82
|
+
*
|
|
83
|
+
* Precedence: newest `outstanding`, else newest `expired`, else newest
|
|
84
|
+
* `resolved`.
|
|
85
|
+
*
|
|
86
|
+
* `outstanding` wins because that is the question an inbox is asking, and
|
|
87
|
+
* because the answer has to be the SAME checkpoint `findPendingCheckpoint`
|
|
88
|
+
* returns. A run can hold several parks — it parks, a human answers, it runs
|
|
89
|
+
* on, it parks again — and it can hold an outstanding one that is older than
|
|
90
|
+
* a resolved one only in the reverse case, where an earlier park expired
|
|
91
|
+
* unanswered and the run was resumed past it. Ranking by recency alone would
|
|
92
|
+
* then hand an inbox a resolved checkpoint and report the live park as
|
|
93
|
+
* nothing.
|
|
94
|
+
*
|
|
95
|
+
* @param checkpoints the run's checkpoints, any order.
|
|
96
|
+
*/
|
|
97
|
+
export function summarizePark(
|
|
98
|
+
checkpoints: readonly IterationCheckpoint[],
|
|
99
|
+
now: number,
|
|
100
|
+
): ParkSummary | undefined {
|
|
101
|
+
let best: ParkSummary | undefined
|
|
102
|
+
let bestRank = -1
|
|
103
|
+
let bestParkedAt = Number.NEGATIVE_INFINITY
|
|
104
|
+
|
|
105
|
+
for (const cp of checkpoints) {
|
|
106
|
+
const pending = cp.pending
|
|
107
|
+
if (!pending) continue
|
|
108
|
+
const summary = toParkSummary(cp, pending, now)
|
|
109
|
+
const rank = PARK_RANK[summary.state]
|
|
110
|
+
if (rank > bestRank || (rank === bestRank && pending.parkedAt > bestParkedAt)) {
|
|
111
|
+
best = summary
|
|
112
|
+
bestRank = rank
|
|
113
|
+
bestParkedAt = pending.parkedAt
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return best
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const PARK_RANK: Record<ParkState, number> = {
|
|
121
|
+
resolved: 0,
|
|
122
|
+
expired: 1,
|
|
123
|
+
outstanding: 2,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Project one run's checkpoints into a listing entry.
|
|
128
|
+
*
|
|
129
|
+
* Returns `null` for a run with no checkpoints: the listing is of runs with
|
|
130
|
+
* DURABLE state, and a run with nothing stored has nothing a sweeper could
|
|
131
|
+
* resume. A disk walk hits this case for real — a sub-run's directory is
|
|
132
|
+
* created as a bare shell under its own id before anything is written to it.
|
|
133
|
+
*/
|
|
134
|
+
export function toDurableRunEntry(
|
|
135
|
+
scope: CheckpointRunScope,
|
|
136
|
+
checkpoints: readonly IterationCheckpoint[],
|
|
137
|
+
now: number,
|
|
138
|
+
): DurableRunEntry | null {
|
|
139
|
+
if (checkpoints.length === 0) return null
|
|
140
|
+
|
|
141
|
+
let latest = checkpoints[0] as IterationCheckpoint
|
|
142
|
+
for (const cp of checkpoints) {
|
|
143
|
+
if (cp.createdAt > latest.createdAt) latest = cp
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const park = summarizePark(checkpoints, now)
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
tenantId: scope.tenantId,
|
|
150
|
+
projectId: scope.projectId,
|
|
151
|
+
sessionId: scope.sessionId,
|
|
152
|
+
runId: scope.runId,
|
|
153
|
+
...(scope.parentRunId ? { parentRunId: scope.parentRunId } : {}),
|
|
154
|
+
checkpointCount: checkpoints.length,
|
|
155
|
+
latestCheckpointId: latest.id,
|
|
156
|
+
latestCheckpointAt: latest.createdAt,
|
|
157
|
+
...(park ? { park } : {}),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Apply the park filter, the contract's ordering and the cursor to a set of
|
|
163
|
+
* entries an implementation has gathered.
|
|
164
|
+
*
|
|
165
|
+
* Both shipped stores gather differently — one walks a directory tree, one
|
|
166
|
+
* reads a map — and then hand the result here, so "ordered by `runId`, page
|
|
167
|
+
* ends where the next begins" is one implementation rather than two.
|
|
168
|
+
*/
|
|
169
|
+
export function paginateDurableRuns(
|
|
170
|
+
entries: readonly DurableRunEntry[],
|
|
171
|
+
options?: ListDurableRunsOptions,
|
|
172
|
+
): DurableRunPage {
|
|
173
|
+
const wanted = options?.park
|
|
174
|
+
const filtered =
|
|
175
|
+
wanted && wanted.length > 0
|
|
176
|
+
? entries.filter((e) => e.park !== undefined && wanted.includes(e.park.state))
|
|
177
|
+
: entries
|
|
178
|
+
|
|
179
|
+
// Ordered by `runId` because it is the only per-run key that cannot move
|
|
180
|
+
// under a paging caller — see the contract comment on `listDurableRuns`.
|
|
181
|
+
const ordered = [...filtered].sort((a, b) => (a.runId < b.runId ? -1 : a.runId > b.runId ? 1 : 0))
|
|
182
|
+
|
|
183
|
+
const after = options?.cursor
|
|
184
|
+
const start = after === undefined ? 0 : ordered.findIndex((e) => e.runId > after)
|
|
185
|
+
const from = start < 0 ? ordered.length : start
|
|
186
|
+
|
|
187
|
+
const limit = Math.max(1, Math.trunc(options?.limit ?? DEFAULT_DURABLE_RUN_LIMIT))
|
|
188
|
+
const page = ordered.slice(from, from + limit)
|
|
189
|
+
const exhausted = from + page.length >= ordered.length
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
entries: page,
|
|
193
|
+
// No cursor when there is nothing behind it, so `while (cursor)`
|
|
194
|
+
// terminates rather than fetching one empty page to find out.
|
|
195
|
+
...(exhausted || page.length === 0
|
|
196
|
+
? {}
|
|
197
|
+
: { cursor: (page[page.length - 1] as DurableRunEntry).runId }),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every run with durable state under a scope, refusing when the store cannot
|
|
203
|
+
* answer.
|
|
204
|
+
*
|
|
205
|
+
* The refusal is the point. `listDurableRuns` is optional on the contract so
|
|
206
|
+
* that adding it did not break every host that had already implemented the
|
|
207
|
+
* interface — and an optional capability reached without a check degrades
|
|
208
|
+
* into a wrong answer: a store that cannot list would hand an approval inbox
|
|
209
|
+
* an empty page, and "nothing is waiting on a human" is not what "I cannot
|
|
210
|
+
* tell" means. A host that gets this error knows to supply a backend that
|
|
211
|
+
* implements the listing; a host that got `[]` would ship an inbox that
|
|
212
|
+
* silently never fires.
|
|
213
|
+
*/
|
|
214
|
+
export async function listDurableRuns(
|
|
215
|
+
store: CheckpointStore,
|
|
216
|
+
scope: CheckpointListingScope,
|
|
217
|
+
options?: ListDurableRunsOptions,
|
|
218
|
+
): Promise<DurableRunPage> {
|
|
219
|
+
if (typeof store.listDurableRuns !== 'function') {
|
|
220
|
+
throw new NamzuError({
|
|
221
|
+
code: 'capability_unavailable',
|
|
222
|
+
message:
|
|
223
|
+
'listDurableRuns: the injected checkpoint store does not implement `listDurableRuns`, so it cannot enumerate runs above a run id. Refusing rather than reporting an empty listing, which would read as "no runs are parked" when the truth is that this store cannot tell. Supply a store that implements it (the built-in disk and in-memory stores both do).',
|
|
224
|
+
details: { tenantId: scope.tenantId },
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
assertContiguousListingScope(scope, 'listDurableRuns')
|
|
228
|
+
return store.listDurableRuns(scope, options)
|
|
229
|
+
}
|
|
@@ -175,12 +175,27 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
|
|
|
175
175
|
// the other one.
|
|
176
176
|
const succeeded = taskSucceeded(completed)
|
|
177
177
|
|
|
178
|
+
// A schema-configured child answers with an OBJECT, and this used to
|
|
179
|
+
// hand the parent model the child's prose instead — so a supervisor
|
|
180
|
+
// fanning out to five specialists got five strings and had to
|
|
181
|
+
// re-parse what it had just caused to be serialized.
|
|
182
|
+
//
|
|
183
|
+
// `structuredOutput` wins over `result` when present, and reading it
|
|
184
|
+
// first is what makes that true. They agree by construction anyway:
|
|
185
|
+
// `setStructuredOutput` serializes the value into `result`, so this
|
|
186
|
+
// preference is about which field is authoritative rather than about
|
|
187
|
+
// which string is produced.
|
|
188
|
+
const structured = completed.result?.structuredOutput
|
|
178
189
|
const resultText =
|
|
179
|
-
|
|
180
|
-
?
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
structured !== undefined
|
|
191
|
+
? typeof structured === 'string'
|
|
192
|
+
? structured
|
|
193
|
+
: JSON.stringify(structured)
|
|
194
|
+
: typeof completed.result?.result === 'string'
|
|
195
|
+
? completed.result.result
|
|
196
|
+
: completed.result?.result !== undefined
|
|
197
|
+
? JSON.stringify(completed.result.result)
|
|
198
|
+
: ''
|
|
184
199
|
|
|
185
200
|
if (!succeeded) {
|
|
186
201
|
const detail =
|
|
@@ -288,6 +288,26 @@ function readPositiveIntEnv(key: string, fallback: number): number {
|
|
|
288
288
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The answer a delegated child produced, as the string a parent model reads.
|
|
293
|
+
*
|
|
294
|
+
* One function because two delegation surfaces ask the same question, and the
|
|
295
|
+
* comment on the other one records what happens when a rule lives at one site
|
|
296
|
+
* only: create_task shipped without the success check that agent already had.
|
|
297
|
+
*
|
|
298
|
+
* A schema-configured child answers with an object. Reading structuredOutput
|
|
299
|
+
* first is what stops a supervisor receiving prose it then has to re-parse.
|
|
300
|
+
*/
|
|
301
|
+
function delegatedAnswer(
|
|
302
|
+
result: { structuredOutput?: unknown; result?: string } | undefined,
|
|
303
|
+
): string | undefined {
|
|
304
|
+
const structured = result?.structuredOutput
|
|
305
|
+
if (structured !== undefined) {
|
|
306
|
+
return typeof structured === 'string' ? structured : JSON.stringify(structured)
|
|
307
|
+
}
|
|
308
|
+
return result?.result
|
|
309
|
+
}
|
|
310
|
+
|
|
291
311
|
export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefinition[] {
|
|
292
312
|
const {
|
|
293
313
|
gateway,
|
|
@@ -614,7 +634,7 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
614
634
|
// the work had been done.
|
|
615
635
|
const success = taskSucceeded(completed)
|
|
616
636
|
const resultText =
|
|
617
|
-
completed.result
|
|
637
|
+
delegatedAnswer(completed.result) ??
|
|
618
638
|
completed.result?.lastError ??
|
|
619
639
|
`Task finished with state: ${failureLabel(completed)}`
|
|
620
640
|
|
|
@@ -744,7 +764,7 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
744
764
|
|
|
745
765
|
const success = completed.state === 'completed'
|
|
746
766
|
const resultText =
|
|
747
|
-
completed.result
|
|
767
|
+
delegatedAnswer(completed.result) ??
|
|
748
768
|
completed.result?.lastError ??
|
|
749
769
|
`Task finished with state: ${completed.state}`
|
|
750
770
|
return {
|
package/src/types/agent/base.ts
CHANGED
|
@@ -186,6 +186,26 @@ export interface BaseAgentResult {
|
|
|
186
186
|
durationMs: number
|
|
187
187
|
messages: Message[]
|
|
188
188
|
result?: string
|
|
189
|
+
/**
|
|
190
|
+
* The schema-validated answer, when the run was configured to produce one.
|
|
191
|
+
*
|
|
192
|
+
* `Run.structuredOutput` has carried this all along and every ergonomic
|
|
193
|
+
* boundary above it dropped the value three lines from its caller: an
|
|
194
|
+
* archetype's result literal did not copy it, `runAgent` did not even
|
|
195
|
+
* forward the config that produces it, and both delegation tools handed a
|
|
196
|
+
* parent the child's prose. So a supervisor fanning out to five
|
|
197
|
+
* schema-configured specialists received five strings and had to make the
|
|
198
|
+
* model re-parse what it had just caused to be serialized.
|
|
199
|
+
*
|
|
200
|
+
* `unknown` rather than a generic, deliberately. The schema lives on the
|
|
201
|
+
* run's config and a result type parameter would have to be threaded
|
|
202
|
+
* through every archetype, both delegation tools and the task record to
|
|
203
|
+
* reach here — and at the delegation boundary the parent does not hold the
|
|
204
|
+
* child's schema anyway, so the parameter would be `unknown` again at the
|
|
205
|
+
* only place it was wanted. Narrow it at the call site with the schema you
|
|
206
|
+
* already have.
|
|
207
|
+
*/
|
|
208
|
+
structuredOutput?: unknown
|
|
189
209
|
lastError?: string
|
|
190
210
|
}
|
|
191
211
|
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
PLUGIN_NAME_MAX_LENGTH,
|
|
10
10
|
} from '../../constants/plugin/index.js'
|
|
11
11
|
import type { PluginId, RunId } from '../ids/index.js'
|
|
12
|
-
import type { Message } from '../message/index.js'
|
|
12
|
+
import type { Message, ToolResultContent } from '../message/index.js'
|
|
13
13
|
import type { ToolResult } from '../tool/index.js'
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -191,6 +191,31 @@ export type PluginHookResult =
|
|
|
191
191
|
| { action: 'modify'; input: unknown }
|
|
192
192
|
| { action: 'error'; message: string }
|
|
193
193
|
| { action: 'retry' }
|
|
194
|
+
/**
|
|
195
|
+
* Replace what the model sees, WITHOUT reporting the call as failed.
|
|
196
|
+
*
|
|
197
|
+
* The substitution seam already existed and was typed as a failure channel:
|
|
198
|
+
* the only way a `post_tool_use` hook could change the output was
|
|
199
|
+
* `action: 'error'`, which prefixes `Error: ` and sets the error flag. So
|
|
200
|
+
* redacting a credential out of a successful result was delivered to the
|
|
201
|
+
* model as a tool failure, and the model routed around a call that had
|
|
202
|
+
* worked — retrying it, or reporting to the user that it had failed.
|
|
203
|
+
*
|
|
204
|
+
* That is the difference this variant exists for. `error` says the call went
|
|
205
|
+
* wrong; this says the call went right and the model may not see all of it.
|
|
206
|
+
*
|
|
207
|
+
* `modify` is not this. It carries `input` and belongs to the pre-call
|
|
208
|
+
* hooks, which is why `post_tool_use` rejects it — a result is not an input,
|
|
209
|
+
* and reusing the variant would have made one action mean two things
|
|
210
|
+
* depending on where it was returned.
|
|
211
|
+
*
|
|
212
|
+
* Rich content blocks SURVIVE a replace unless `content` is given, because
|
|
213
|
+
* the common case is redacting text from a result whose image or resource
|
|
214
|
+
* is unaffected. A hook that needs to drop them passes `content: []`, and a
|
|
215
|
+
* hook redacting a secret that also appears in an image must — this variant
|
|
216
|
+
* cannot inspect what it is preserving.
|
|
217
|
+
*/
|
|
218
|
+
| { action: 'replace'; output: string; content?: ToolResultContent }
|
|
194
219
|
|
|
195
220
|
export function assertPluginHookResult(result: PluginHookResult): asserts result {
|
|
196
221
|
const action = result.action
|
|
@@ -200,6 +225,7 @@ export function assertPluginHookResult(result: PluginHookResult): asserts result
|
|
|
200
225
|
case 'modify':
|
|
201
226
|
case 'error':
|
|
202
227
|
case 'retry':
|
|
228
|
+
case 'replace':
|
|
203
229
|
break
|
|
204
230
|
default: {
|
|
205
231
|
const _exhaustive: never = action
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* five-layer attribution (Convention #17) instead of a filesystem path.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { CheckpointId, IterationCheckpoint } from '../hitl/index.js'
|
|
12
|
+
import type { CheckpointId, HITLDecisionRequest, IterationCheckpoint } from '../hitl/index.js'
|
|
13
13
|
import type { RunId, SessionId, TenantId } from '../ids/index.js'
|
|
14
14
|
import type { ProjectId } from '../session/ids.js'
|
|
15
15
|
|
|
@@ -39,6 +39,160 @@ export interface CheckpointRunScope {
|
|
|
39
39
|
parentRunId?: RunId
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* A CONTIGUOUS PREFIX of the run attribution hierarchy, addressing a SET of
|
|
44
|
+
* runs rather than one.
|
|
45
|
+
*
|
|
46
|
+
* A separate type from {@link CheckpointRunScope} on purpose. That type
|
|
47
|
+
* addresses exactly one run and four accessors depend on it doing so; making
|
|
48
|
+
* its one distinguishing field optional in place would turn "the scope of a
|
|
49
|
+
* run" into "some identifiers, maybe", and every accessor's guarantee with
|
|
50
|
+
* it.
|
|
51
|
+
*
|
|
52
|
+
* Three properties, each deliberate:
|
|
53
|
+
*
|
|
54
|
+
* - **`tenantId` is required.** Isolation is the one boundary that is never
|
|
55
|
+
* optional here. An untenanted listing is a cross-tenant read with a
|
|
56
|
+
* friendly name.
|
|
57
|
+
* - **It stops ABOVE the run.** No `runId`, no `parentRunId`. A caller
|
|
58
|
+
* holding a run id already has a full {@link CheckpointRunScope} and four
|
|
59
|
+
* accessors that take it; admitting one here would make
|
|
60
|
+
* `CheckpointRunScope` structurally assignable to this type and re-merge
|
|
61
|
+
* the two ideas the split exists to keep apart.
|
|
62
|
+
* - **The prefix must be contiguous.** A `sessionId` with no `projectId` is
|
|
63
|
+
* REFUSED, not silently widened to "that session under whichever project
|
|
64
|
+
* holds it". A flat backend can answer it and a hierarchical one cannot,
|
|
65
|
+
* so the answer would depend on the backend's storage shape — which is
|
|
66
|
+
* the one thing a store contract exists to hide.
|
|
67
|
+
*/
|
|
68
|
+
export interface CheckpointListingScope {
|
|
69
|
+
/** Isolation boundary (Convention #17). Never optional. */
|
|
70
|
+
readonly tenantId: TenantId
|
|
71
|
+
/** Narrow to one project. Absent = every project of the tenant. */
|
|
72
|
+
readonly projectId?: ProjectId
|
|
73
|
+
/** Narrow to one session. Requires `projectId`. */
|
|
74
|
+
readonly sessionId?: SessionId
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What a run's human-in-the-loop park is doing, as far as durable state can
|
|
79
|
+
* tell.
|
|
80
|
+
*
|
|
81
|
+
* A closed union rather than a boolean because the two unanswered states are
|
|
82
|
+
* drained by DIFFERENT operators: `outstanding` is an approval inbox's queue
|
|
83
|
+
* and `expired` is a reclamation sweep's, and serving one to the other either
|
|
84
|
+
* re-presents a dead approval forever or discards a live one.
|
|
85
|
+
*/
|
|
86
|
+
export type ParkState =
|
|
87
|
+
/** `pending` set, no `resolvedAt`, deadline not passed. A human owes an answer. */
|
|
88
|
+
| 'outstanding'
|
|
89
|
+
/** `pending` set, no `resolvedAt`, deadline passed. Nobody will answer it. */
|
|
90
|
+
| 'expired'
|
|
91
|
+
/** `pending` set with `resolvedAt`. Kept as evidence of who decided what. */
|
|
92
|
+
| 'resolved'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* **Do not widen this union to say who is working on the run.**
|
|
96
|
+
*
|
|
97
|
+
* A consumer switches over `ParkState` exhaustively, so a fourth member is a
|
|
98
|
+
* backward-incompatible change and a `major` — and the pull to add one is
|
|
99
|
+
* real, because the next capability this contract takes is a cross-process
|
|
100
|
+
* claim, and a queue worker draining the inbox wants to skip runs another
|
|
101
|
+
* worker already holds.
|
|
102
|
+
*
|
|
103
|
+
* That is a different fact about a different subject. A park is a question
|
|
104
|
+
* put to a HUMAN; a claim is a lease held by a PROCESS, and one run can have
|
|
105
|
+
* both, neither, or either. Encoding them in one union makes the pair
|
|
106
|
+
* unsayable and loses the state a worker needs most: parked AND unclaimed.
|
|
107
|
+
*
|
|
108
|
+
* The additive shape is a sibling optional field — `claim?: …` on
|
|
109
|
+
* {@link DurableRunEntry}, `claimed?: …` on {@link ListDurableRunsOptions}.
|
|
110
|
+
* A consumer reading rows is not broken by a new optional field, so the
|
|
111
|
+
* claim ships as a second `minor` on this contract rather than a second
|
|
112
|
+
* migration of it. This note exists because the union is the obvious place
|
|
113
|
+
* to reach for and the wrong one.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/** A run's park disposition, projected from the checkpoint that carries it. */
|
|
117
|
+
export interface ParkSummary {
|
|
118
|
+
readonly state: ParkState
|
|
119
|
+
/** The parked checkpoint — address it directly with `readCheckpoint`. */
|
|
120
|
+
readonly checkpointId: CheckpointId
|
|
121
|
+
/** What the human was asked. Enough to route an inbox without a second read. */
|
|
122
|
+
readonly requestType: HITLDecisionRequest['type']
|
|
123
|
+
/** Epoch ms at which the run parked. */
|
|
124
|
+
readonly parkedAt: number
|
|
125
|
+
/** Absolute expiry, when the park carries one. */
|
|
126
|
+
readonly deadlineAt?: number
|
|
127
|
+
/** Epoch ms at which the answer arrived. Only on `resolved`. */
|
|
128
|
+
readonly resolvedAt?: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One run that has durable checkpoint state under the queried scope.
|
|
133
|
+
*
|
|
134
|
+
* **Extends {@link CheckpointRunScope}, and that is the load-bearing part.**
|
|
135
|
+
* A listing whose rows cannot be turned back into an addressable scope is a
|
|
136
|
+
* report, not a work queue. Because an entry IS a run scope,
|
|
137
|
+
* `findPendingCheckpoint(store, entry)`, `new CheckpointManager(store, entry)`
|
|
138
|
+
* and `resumeRun({ scope: entry, … })` all accept a row straight out of the
|
|
139
|
+
* listing — with no re-assembly, and so no chance of assembling it wrong.
|
|
140
|
+
*
|
|
141
|
+
* ### What an entry deliberately does NOT carry
|
|
142
|
+
*
|
|
143
|
+
* A run STATUS. A checkpoint is written mid-flight, so nothing in this store
|
|
144
|
+
* distinguishes a run that finished from one that died — the same fact
|
|
145
|
+
* {@link import('../../runtime/query/run-state.js').loadRunState} already
|
|
146
|
+
* states, where a rebuilt snapshot always reports `running` and the host's
|
|
147
|
+
* own record stays the authority. A `status` field here would answer
|
|
148
|
+
* "mid-flight" for every run that ever succeeded, and a sweeper built on it
|
|
149
|
+
* would resume finished work.
|
|
150
|
+
*
|
|
151
|
+
* A crash sweep is therefore: list every run with durable state under the
|
|
152
|
+
* scope, intersect with the host's own run records, resume the difference.
|
|
153
|
+
*/
|
|
154
|
+
export interface DurableRunEntry extends CheckpointRunScope {
|
|
155
|
+
/** How many checkpoints the run has right now. Pruning lowers it. */
|
|
156
|
+
readonly checkpointCount: number
|
|
157
|
+
/** Newest checkpoint by `createdAt` — the one a resume restores by default. */
|
|
158
|
+
readonly latestCheckpointId: CheckpointId
|
|
159
|
+
/** `createdAt` of {@link DurableRunEntry.latestCheckpointId}. */
|
|
160
|
+
readonly latestCheckpointAt: number
|
|
161
|
+
/** Absent when the run has never parked. */
|
|
162
|
+
readonly park?: ParkSummary
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Filters and paging for {@link CheckpointStore.listDurableRuns}. */
|
|
166
|
+
export interface ListDurableRunsOptions {
|
|
167
|
+
/**
|
|
168
|
+
* Keep only runs whose park is in one of these states. A run that never
|
|
169
|
+
* parked has no state and is excluded by ANY value here; omit the filter
|
|
170
|
+
* to include it.
|
|
171
|
+
*/
|
|
172
|
+
readonly park?: readonly ParkState[]
|
|
173
|
+
/** Page size. Defaults to 100, clamped to at least 1. */
|
|
174
|
+
readonly limit?: number
|
|
175
|
+
/** Resume token from the previous page's {@link DurableRunPage.cursor}. */
|
|
176
|
+
readonly cursor?: string
|
|
177
|
+
/**
|
|
178
|
+
* Clock for expiry, so a sweep can be tested and so every entry in one
|
|
179
|
+
* page is judged against the same instant. Defaults to `Date.now()` — the
|
|
180
|
+
* same seam `findPendingCheckpoint` already takes.
|
|
181
|
+
*/
|
|
182
|
+
readonly now?: number
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One page of {@link DurableRunEntry}. */
|
|
186
|
+
export interface DurableRunPage {
|
|
187
|
+
readonly entries: readonly DurableRunEntry[]
|
|
188
|
+
/**
|
|
189
|
+
* Pass to the next call. **Absent means the listing is exhausted**, so
|
|
190
|
+
* `while (cursor)` terminates; a store never returns a cursor it already
|
|
191
|
+
* knows yields nothing.
|
|
192
|
+
*/
|
|
193
|
+
readonly cursor?: string
|
|
194
|
+
}
|
|
195
|
+
|
|
42
196
|
/**
|
|
43
197
|
* Persistence contract consumed by
|
|
44
198
|
* {@link import('../../runtime/query/checkpoint.js').CheckpointManager} and
|
|
@@ -49,6 +203,27 @@ export interface CheckpointRunScope {
|
|
|
49
203
|
* not-found error. `deleteCheckpoint` is idempotent: deleting an absent
|
|
50
204
|
* checkpoint succeeds as a no-op (mirrors the disk store's ENOENT
|
|
51
205
|
* swallowing).
|
|
206
|
+
*
|
|
207
|
+
* ## Optional capabilities, and the rule that comes with them
|
|
208
|
+
*
|
|
209
|
+
* {@link CheckpointStore.listDurableRuns} is optional, following
|
|
210
|
+
* `SessionStore.listSessionsByProject`. A required method would break every
|
|
211
|
+
* host that has already implemented this interface, which is a `major` for
|
|
212
|
+
* what is otherwise an additive capability.
|
|
213
|
+
*
|
|
214
|
+
* The rule optionality obliges: **a caller of an optional capability REFUSES
|
|
215
|
+
* when it is absent; it never degrades.** An approval inbox built on a store
|
|
216
|
+
* that cannot list has to throw, because an empty page would say "nothing is
|
|
217
|
+
* waiting on a human" when the truth is "I cannot tell" — an optional
|
|
218
|
+
* dependency degrading a check, which this repository has been bitten by
|
|
219
|
+
* before. Reach the capability through
|
|
220
|
+
* {@link import('../../store/run/listing.js').listDurableRuns}, which
|
|
221
|
+
* refuses on absence rather than answering.
|
|
222
|
+
*
|
|
223
|
+
* Any capability added here later takes the same shape — optional method,
|
|
224
|
+
* refusing helper. A cross-process claim is the next one, and a two-worker
|
|
225
|
+
* deployment against a store with no lease has to fail loudly rather than
|
|
226
|
+
* proceed.
|
|
52
227
|
*/
|
|
53
228
|
export interface CheckpointStore {
|
|
54
229
|
/** Persist one checkpoint. Overwrites an existing checkpoint with the same id. */
|
|
@@ -69,4 +244,49 @@ export interface CheckpointStore {
|
|
|
69
244
|
|
|
70
245
|
/** Delete a checkpoint by id. Absent checkpoints succeed as a no-op. */
|
|
71
246
|
deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void>
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Every run with durable checkpoint state under a scope ABOVE the run.
|
|
250
|
+
* OPTIONAL — see the optional-capability rule on this interface.
|
|
251
|
+
*
|
|
252
|
+
* This is the read an approval inbox and a park sweep are built from, and
|
|
253
|
+
* the one thing this contract had no way to express: every other accessor
|
|
254
|
+
* needs a `runId`, so a host could only ask about runs it already knew
|
|
255
|
+
* about. `hitlParkTtlMs` documents a host sweep as the reclamation path
|
|
256
|
+
* for an unanswered park, and until this existed the sweep had no way to
|
|
257
|
+
* enumerate what to sweep.
|
|
258
|
+
*
|
|
259
|
+
* ### Ordering, and why it is not chronological
|
|
260
|
+
*
|
|
261
|
+
* Rows come back ordered by `runId` ascending, and the cursor is a
|
|
262
|
+
* position in that order.
|
|
263
|
+
*
|
|
264
|
+
* A cursor has to sort on a key that cannot move, or a paging caller
|
|
265
|
+
* skips rows and repeats rows. Every time-valued key this store can
|
|
266
|
+
* derive per run DOES move: the newest checkpoint's timestamp advances
|
|
267
|
+
* whenever the run checkpoints again, and the oldest one's advances
|
|
268
|
+
* whenever `CheckpointManager.prune` deletes oldest-first, which is what
|
|
269
|
+
* pruning does. `runId` is the only immutable, unique per-run key
|
|
270
|
+
* available, and being unique it is already a total order — the
|
|
271
|
+
* degenerate case of the rule `orderChildren` follows (sort on a key that
|
|
272
|
+
* cannot move, make the order total with an id), not a departure from it.
|
|
273
|
+
*
|
|
274
|
+
* The cost is that page order is arbitrary rather than oldest-first,
|
|
275
|
+
* because run ids carry no timestamp. Entries carry `latestCheckpointAt`
|
|
276
|
+
* and `park.parkedAt` so a caller can sort what it has read.
|
|
277
|
+
*
|
|
278
|
+
* A run whose FIRST checkpoint is written after paging began may be
|
|
279
|
+
* missed by that pass — it lands at whatever `runId` it minted, possibly
|
|
280
|
+
* behind the cursor. That is the right trade for a queue: the sweep runs
|
|
281
|
+
* again and picks it up next pass, whereas a moving sort key loses runs
|
|
282
|
+
* that already existed.
|
|
283
|
+
*
|
|
284
|
+
* @param scope contiguous prefix; `tenantId` required. Implementations
|
|
285
|
+
* reject a hole (`sessionId` with no `projectId`) rather than guessing.
|
|
286
|
+
* @param options filters and paging. See {@link ListDurableRunsOptions}.
|
|
287
|
+
*/
|
|
288
|
+
listDurableRuns?(
|
|
289
|
+
scope: CheckpointListingScope,
|
|
290
|
+
options?: ListDurableRunsOptions,
|
|
291
|
+
): Promise<DurableRunPage>
|
|
72
292
|
}
|