@namzu/sdk 53.1.0 → 53.2.1
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 +19 -0
- package/dist/compaction/index.d.ts +1 -1
- package/dist/compaction/index.d.ts.map +1 -1
- package/dist/compaction/index.js +1 -1
- package/dist/compaction/index.js.map +1 -1
- package/dist/compaction/manual.d.ts +5 -0
- package/dist/compaction/manual.d.ts.map +1 -1
- package/dist/compaction/manual.js +4 -4
- package/dist/compaction/manual.js.map +1 -1
- package/dist/compaction/salience/bm25.d.ts +1 -1
- package/dist/compaction/salience/bm25.d.ts.map +1 -1
- package/dist/compaction/salience/bm25.js +1 -2
- package/dist/compaction/salience/bm25.js.map +1 -1
- package/dist/compaction/salience/score.d.ts.map +1 -1
- package/dist/compaction/salience/score.js +1 -1
- package/dist/compaction/salience/score.js.map +1 -1
- package/dist/compaction/salience/tokenize.d.ts.map +1 -1
- package/dist/compaction/salience/tokenize.js +16 -2
- package/dist/compaction/salience/tokenize.js.map +1 -1
- package/dist/compaction/summary.d.ts +12 -2
- package/dist/compaction/summary.d.ts.map +1 -1
- package/dist/compaction/summary.js +13 -3
- package/dist/compaction/summary.js.map +1 -1
- package/dist/compaction/tool-result-editing.d.ts.map +1 -1
- package/dist/compaction/tool-result-editing.js +9 -6
- package/dist/compaction/tool-result-editing.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts +15 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +169 -1
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +2 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/executor.d.ts +2 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +21 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +6 -2
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/tool-output-budget.d.ts +30 -2
- package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
- package/dist/runtime/query/tool-output-budget.js +43 -10
- package/dist/runtime/query/tool-output-budget.js.map +1 -1
- package/dist/scheduler/local.d.ts +2 -1
- package/dist/scheduler/local.d.ts.map +1 -1
- package/dist/scheduler/local.js +8 -0
- package/dist/scheduler/local.js.map +1 -1
- package/dist/store/evidence/disk.d.ts +3 -2
- package/dist/store/evidence/disk.d.ts.map +1 -1
- package/dist/store/evidence/disk.js +23 -3
- package/dist/store/evidence/disk.js.map +1 -1
- package/dist/store/evidence/source-text.d.ts +20 -0
- package/dist/store/evidence/source-text.d.ts.map +1 -1
- package/dist/store/evidence/source-text.js +29 -2
- package/dist/store/evidence/source-text.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +7 -2
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/retained-output.d.ts +12 -0
- package/dist/tools/retained-output.d.ts.map +1 -0
- package/dist/tools/retained-output.js +39 -0
- package/dist/tools/retained-output.js.map +1 -0
- package/dist/types/agent/manager.d.ts +48 -0
- package/dist/types/agent/manager.d.ts.map +1 -1
- package/dist/types/agent/scheduler.d.ts +20 -0
- package/dist/types/agent/scheduler.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +2 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/session/records.d.ts +25 -0
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +16 -0
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/tool/index.d.ts +8 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/compaction/index.ts +6 -1
- package/src/compaction/manual.ts +15 -3
- package/src/compaction/salience/bm25.ts +4 -2
- package/src/compaction/salience/score.ts +3 -1
- package/src/compaction/salience/tokenize.ts +16 -2
- package/src/compaction/summary.ts +17 -3
- package/src/compaction/tool-result-editing.ts +12 -9
- package/src/manager/agent/lifecycle.ts +181 -1
- package/src/public-runtime.ts +2 -1
- package/src/runtime/query/executor.ts +25 -1
- package/src/runtime/query/iteration/phases/compaction.ts +13 -2
- package/src/runtime/query/tool-output-budget.ts +65 -11
- package/src/scheduler/local.ts +14 -1
- package/src/store/evidence/disk.ts +28 -3
- package/src/store/evidence/source-text.ts +38 -2
- package/src/tools/builtins/read-file.ts +7 -2
- package/src/tools/retained-output.ts +38 -0
- package/src/types/agent/manager.ts +54 -0
- package/src/types/agent/scheduler.ts +21 -0
- package/src/types/agent/task.ts +2 -0
- package/src/types/session/records.ts +16 -0
- package/src/types/tool/index.ts +8 -0
|
@@ -42,6 +42,58 @@ export const MESSAGE_BUDGET_PREVIEW_CHARS = 2_000
|
|
|
42
42
|
*/
|
|
43
43
|
export const SPILL_MARKER = 'The full output was written to:'
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The recovery routes a host has mounted for a retained output. A pointer
|
|
47
|
+
* names only a tool that exists, so the same sentence is true wherever it is
|
|
48
|
+
* read: in the result, in the cleared placeholder that keeps it, and in a
|
|
49
|
+
* summary that quotes it.
|
|
50
|
+
*/
|
|
51
|
+
export interface RetainedOutputRoutes {
|
|
52
|
+
/** The `read` tool is mounted and may open this session's own retained files. */
|
|
53
|
+
readonly read?: boolean
|
|
54
|
+
/** `search_conversation` and `read_conversation` are both mounted. */
|
|
55
|
+
readonly conversation?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Opening of the sentence that follows the spill line; how a pointer is recognised. */
|
|
59
|
+
export const RETAINED_OUTPUT_NOTE = 'This path identifies retained output, not the original input.'
|
|
60
|
+
|
|
61
|
+
const REPLAY_WARNING =
|
|
62
|
+
'A fresh observation of the original input cannot recover its earlier contents. If recovery is unavailable, report the missing detail; do not replay the original action.'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The one sentence that says how to get at a retained output. Without routes
|
|
66
|
+
* (an embedder that told the budget nothing) it keeps the neutral wording.
|
|
67
|
+
*/
|
|
68
|
+
export function describeRetainedOutputRecovery(routes?: RetainedOutputRoutes): string {
|
|
69
|
+
const read = routes?.read === true
|
|
70
|
+
const conversation = routes?.conversation === true
|
|
71
|
+
const how = [
|
|
72
|
+
read ? 'To read more of it, open this file with the read tool (offset and limit).' : '',
|
|
73
|
+
conversation
|
|
74
|
+
? read
|
|
75
|
+
? 'For anything else from earlier in this conversation, call search_conversation with a phrase from it, then read_conversation with the seq and part it returns.'
|
|
76
|
+
: 'To read more of it, call search_conversation with a phrase from it, then read_conversation with the seq and part it returns. The path is not a workspace file, so read and grep cannot open it.'
|
|
77
|
+
: '',
|
|
78
|
+
].filter(Boolean)
|
|
79
|
+
if (how.length === 0)
|
|
80
|
+
return `${RETAINED_OUTPUT_NOTE} Use the host-authorized retained-output recovery tools when available; the path does not grant filesystem access. ${REPLAY_WARNING}`
|
|
81
|
+
return [RETAINED_OUTPUT_NOTE, ...how, REPLAY_WARNING].join(' ')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The spill line and the recovery sentence under it, when `text` carries them.
|
|
86
|
+
* Whatever shortens a result keeps this pair, so the route survives.
|
|
87
|
+
*/
|
|
88
|
+
export function retainedOutputPointer(text: string): string | undefined {
|
|
89
|
+
// A tool may end its lines with \r\n or a bare \r; the pointer is one line either way.
|
|
90
|
+
const lines = text.split(/\r\n|\r|\n/)
|
|
91
|
+
const at = lines.findIndex((line) => line.startsWith(SPILL_MARKER))
|
|
92
|
+
if (at < 0) return undefined
|
|
93
|
+
const next = lines[at + 1]
|
|
94
|
+
return next?.startsWith(RETAINED_OUTPUT_NOTE) ? `${lines[at]}\n${next}` : (lines[at] as string)
|
|
95
|
+
}
|
|
96
|
+
|
|
45
97
|
/**
|
|
46
98
|
* Share of the budget spent on the head. The rest goes to the tail.
|
|
47
99
|
*
|
|
@@ -91,7 +143,7 @@ function boundedPreview(
|
|
|
91
143
|
'',
|
|
92
144
|
].join('\n')
|
|
93
145
|
const compactMiddle = '\n[... omitted ...]\n'
|
|
94
|
-
const spillLine = recovery.split(
|
|
146
|
+
const spillLine = recovery.split(/\r\n|\r|\n/).find((line) => line.startsWith(SPILL_MARKER))
|
|
95
147
|
const compactRecovery = spillLine ? `${compactMiddle}${spillLine}\n` : compactMiddle
|
|
96
148
|
const middle =
|
|
97
149
|
detailedMiddle.length < maxChars
|
|
@@ -182,6 +234,8 @@ export interface ApplyToolOutputBudgetOptions {
|
|
|
182
234
|
* middle-elided instead — degraded, but never unbounded.
|
|
183
235
|
*/
|
|
184
236
|
readonly spillDir?: string | undefined
|
|
237
|
+
/** Which recovery routes the host has mounted; see {@link describeRetainedOutputRecovery}. */
|
|
238
|
+
readonly recoveryRoutes?: RetainedOutputRoutes
|
|
185
239
|
readonly onError?: (message: string) => void
|
|
186
240
|
}
|
|
187
241
|
|
|
@@ -189,8 +243,9 @@ export interface ApplyToolOutputBudgetOptions {
|
|
|
189
243
|
* Bound a tool result to the model-visible budget.
|
|
190
244
|
*
|
|
191
245
|
* Retention keeps the original while bounding its model-visible preview.
|
|
192
|
-
* The
|
|
193
|
-
*
|
|
246
|
+
* The recovery sentence names only the routes the host says are mounted
|
|
247
|
+
* ({@link RetainedOutputRoutes}); a spill path alone is not proof that a
|
|
248
|
+
* workspace tool can open it. Middle-elision is the
|
|
194
249
|
* fallback for a turn with no directory to write to.
|
|
195
250
|
*
|
|
196
251
|
* The preview keeps head AND tail because the two ends carry different
|
|
@@ -224,10 +279,9 @@ export function applyToolOutputBudget(opts: ApplyToolOutputBudgetOptions): ToolO
|
|
|
224
279
|
|
|
225
280
|
const spillPath = retained?.path
|
|
226
281
|
const recovery = spillPath
|
|
227
|
-
? [
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
].join('\n')
|
|
282
|
+
? [`${SPILL_MARKER} ${spillPath}`, describeRetainedOutputRecovery(opts.recoveryRoutes)].join(
|
|
283
|
+
'\n',
|
|
284
|
+
)
|
|
231
285
|
: 'The full output was not retained. Use a saved artifact or a read-only observation; do not repeat a state-changing action to recover its output.'
|
|
232
286
|
const requestedPreview = opts.retainedPreviewChars
|
|
233
287
|
// Separate the spill threshold from the cost of carrying its preview on every
|
|
@@ -358,11 +412,10 @@ export interface MessageBudgetReplacement {
|
|
|
358
412
|
export interface FitResultsToMessageBudgetOptions {
|
|
359
413
|
readonly maxChars: number
|
|
360
414
|
readonly spillDir?: string | undefined
|
|
415
|
+
readonly recoveryRoutes?: RetainedOutputRoutes
|
|
361
416
|
readonly onError?: (message: string) => void
|
|
362
417
|
}
|
|
363
418
|
|
|
364
|
-
const SPILL_LINE = new RegExp(`^${SPILL_MARKER} .+$`, 'm')
|
|
365
|
-
|
|
366
419
|
/**
|
|
367
420
|
* Hold one assistant message's combined tool results under a cap.
|
|
368
421
|
*
|
|
@@ -409,7 +462,7 @@ function previewForMessageBudget(
|
|
|
409
462
|
item: MessageBudgetItem,
|
|
410
463
|
opts: FitResultsToMessageBudgetOptions,
|
|
411
464
|
): MessageBudgetReplacement {
|
|
412
|
-
const spilledAt =
|
|
465
|
+
const spilledAt = retainedOutputPointer(item.output)
|
|
413
466
|
if (spilledAt !== undefined) {
|
|
414
467
|
const output = [
|
|
415
468
|
safeHead(item.output, MESSAGE_BUDGET_PREVIEW_CHARS / 2),
|
|
@@ -420,7 +473,7 @@ function previewForMessageBudget(
|
|
|
420
473
|
return {
|
|
421
474
|
output,
|
|
422
475
|
originalLength: item.output.length,
|
|
423
|
-
spillPath: spilledAt.slice(SPILL_MARKER.length + 1),
|
|
476
|
+
spillPath: (spilledAt.split('\n')[0] as string).slice(SPILL_MARKER.length + 1),
|
|
424
477
|
}
|
|
425
478
|
}
|
|
426
479
|
const budgeted = applyToolOutputBudget({
|
|
@@ -430,6 +483,7 @@ function previewForMessageBudget(
|
|
|
430
483
|
output: item.output,
|
|
431
484
|
maxChars: MESSAGE_BUDGET_PREVIEW_CHARS,
|
|
432
485
|
spillDir: opts.spillDir,
|
|
486
|
+
...(opts.recoveryRoutes ? { recoveryRoutes: opts.recoveryRoutes } : {}),
|
|
433
487
|
...(opts.onError ? { onError: opts.onError } : {}),
|
|
434
488
|
})
|
|
435
489
|
return {
|
package/src/scheduler/local.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { GENAI } from '../constants/telemetry/index.js'
|
|
2
2
|
import { taskFailed } from '../tools/coordinator/outcome.js'
|
|
3
3
|
import type { AgentInput } from '../types/agent/base.js'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AgentManagerContract,
|
|
6
|
+
RestoreChildOptions,
|
|
7
|
+
RestoreChildResult,
|
|
8
|
+
} from '../types/agent/manager.js'
|
|
5
9
|
import type {
|
|
6
10
|
CreateTaskOptions,
|
|
7
11
|
SiblingFailurePolicy,
|
|
@@ -113,6 +117,9 @@ export class LocalTaskScheduler implements TaskScheduler {
|
|
|
113
117
|
{
|
|
114
118
|
agentId: options.agentId,
|
|
115
119
|
...(options.resumeSessionId ? { resumeSessionId: options.resumeSessionId } : {}),
|
|
120
|
+
...(options.continuationProfile
|
|
121
|
+
? { continuationProfile: options.continuationProfile }
|
|
122
|
+
: {}),
|
|
116
123
|
beforeStart: options.beforeStart,
|
|
117
124
|
...(options.workspace ? { workspace: options.workspace } : {}),
|
|
118
125
|
...(options.planId ? { planId: options.planId } : {}),
|
|
@@ -363,6 +370,12 @@ export class LocalTaskScheduler implements TaskScheduler {
|
|
|
363
370
|
await this.agentManager.continueTask(taskId, message)
|
|
364
371
|
}
|
|
365
372
|
|
|
373
|
+
async restoreChild(options: RestoreChildOptions): Promise<RestoreChildResult> {
|
|
374
|
+
if (!this.agentManager.restoreChildConversation)
|
|
375
|
+
return { status: 'refused', reason: 'no-layout' }
|
|
376
|
+
return this.agentManager.restoreChildConversation(options, this.taskContext)
|
|
377
|
+
}
|
|
378
|
+
|
|
366
379
|
cancelTask(taskId: TaskId, cause?: CancelCause): void {
|
|
367
380
|
this.agentManager.cancel(taskId, cause)
|
|
368
381
|
}
|
|
@@ -110,8 +110,9 @@ export interface EvidenceAnchor {
|
|
|
110
110
|
* session log, or one turn of it. Closed by default (no active turn in scope);
|
|
111
111
|
* snapshot mode also reads a session whose turn is running, without assuming
|
|
112
112
|
* its writer is dead. The host owns authorization and the log's directory.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
113
|
+
* An address names a record by offset, length and digest and survives appends
|
|
114
|
+
* to the log; a replaced or rewritten log is refused and record and chunk
|
|
115
|
+
* digests detect changed bytes. This is not a hostile filesystem sandbox.
|
|
115
116
|
*/
|
|
116
117
|
export function createSessionTextEvidenceSource(
|
|
117
118
|
options: SessionEvidenceSourceOptions,
|
|
@@ -261,9 +262,18 @@ function createSource(
|
|
|
261
262
|
if (!state.closed && consistency === 'closed') {
|
|
262
263
|
throw new Error('Evidence scope has an active turn; it is not closed.')
|
|
263
264
|
}
|
|
265
|
+
// The anchored prefix is verified here and again after the action. It is not
|
|
266
|
+
// part of the seal: the anchor moves with every record the writer appends, so
|
|
267
|
+
// binding it would void an address the moment the search call that returned
|
|
268
|
+
// it is itself recorded.
|
|
264
269
|
const anchorKey = anchor ? await verifyAnchor(handle, anchor) : undefined
|
|
270
|
+
// A session log only grows. An address names one record by offset, length and
|
|
271
|
+
// digest, and a read verifies all three against the bytes, so what must
|
|
272
|
+
// bind a seal is the file's identity, never its size or times: those change
|
|
273
|
+
// with every append and would void every address and cursor handed out a
|
|
274
|
+
// moment earlier, including one a search just returned.
|
|
265
275
|
const sourceKey = digest(
|
|
266
|
-
`session-v1:${consistency}:${mode}:${scopeKey}:${
|
|
276
|
+
`session-v1:${consistency}:${mode}:${scopeKey}:${fileIdentity(before)}`,
|
|
267
277
|
)
|
|
268
278
|
const seal = evidenceSeal(state.first, sourceKey)
|
|
269
279
|
const value = await action(handle, size, seal, sourceKey, budget, !state.closed)
|
|
@@ -552,6 +562,21 @@ function createSource(
|
|
|
552
562
|
})
|
|
553
563
|
}
|
|
554
564
|
|
|
565
|
+
/**
|
|
566
|
+
* What names the log file across appends: device and inode, plus the creation
|
|
567
|
+
* time where the filesystem reports no inode (zero), so a replaced file still
|
|
568
|
+
* changes the identity there.
|
|
569
|
+
*/
|
|
570
|
+
function fileIdentity(stats: {
|
|
571
|
+
dev: number | bigint
|
|
572
|
+
ino: number | bigint
|
|
573
|
+
birthtimeMs: number
|
|
574
|
+
}): string {
|
|
575
|
+
return Number(stats.ino) === 0
|
|
576
|
+
? `${stats.dev}:0:${stats.birthtimeMs}`
|
|
577
|
+
: `${stats.dev}:${stats.ino}`
|
|
578
|
+
}
|
|
579
|
+
|
|
555
580
|
/** The anchor record must still hold the bytes the writer vouched for. */
|
|
556
581
|
async function verifyAnchor(handle: FileHandle, anchor: EvidenceAnchor): Promise<string> {
|
|
557
582
|
const bytes = await readBytes(handle, anchor.offset, anchor.length, {
|
|
@@ -235,6 +235,28 @@ async function sourceText(
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* @experimental A read named a byte offset the retained text cannot start at.
|
|
240
|
+
* `inside_character` means the offset falls inside a multi-byte character (the
|
|
241
|
+
* character starts up to three bytes earlier); `past_end` means it lies beyond
|
|
242
|
+
* the text. Every other read failure (integrity, ownership, a changed source)
|
|
243
|
+
* is a plain `Error`, so a caller can recover from a bad position without
|
|
244
|
+
* treating a refusal as one.
|
|
245
|
+
*/
|
|
246
|
+
export class SessionEvidenceOffsetError extends Error {
|
|
247
|
+
constructor(
|
|
248
|
+
readonly code: 'inside_character' | 'past_end',
|
|
249
|
+
message: string,
|
|
250
|
+
/** Bytes of the retained text. */
|
|
251
|
+
readonly totalBytes: number,
|
|
252
|
+
/** Bytes this failed read was charged. */
|
|
253
|
+
readonly scannedBytes: number,
|
|
254
|
+
) {
|
|
255
|
+
super(message)
|
|
256
|
+
this.name = 'SessionEvidenceOffsetError'
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
238
260
|
export async function readTextPage(
|
|
239
261
|
source: TextSource,
|
|
240
262
|
scope: SessionEvidenceScope,
|
|
@@ -242,13 +264,27 @@ export async function readTextPage(
|
|
|
242
264
|
budget: EvidenceBudget,
|
|
243
265
|
): Promise<SessionTextEvidenceReadResult> {
|
|
244
266
|
const entry = source.entry
|
|
245
|
-
if (offset > source.bytes)
|
|
267
|
+
if (offset > source.bytes)
|
|
268
|
+
throw new SessionEvidenceOffsetError(
|
|
269
|
+
'past_end',
|
|
270
|
+
'Offset exceeds retained text.',
|
|
271
|
+
source.bytes,
|
|
272
|
+
budget.bytes,
|
|
273
|
+
)
|
|
246
274
|
const chunk = entry.spill
|
|
247
275
|
? Math.min(Math.floor(offset / EVIDENCE_CHUNK_BYTES), source.chunks - 1)
|
|
248
276
|
: 0
|
|
249
277
|
const window = await source.window(chunk)
|
|
250
|
-
|
|
278
|
+
const splits = () =>
|
|
279
|
+
new SessionEvidenceOffsetError(
|
|
280
|
+
'inside_character',
|
|
281
|
+
'Offset splits a UTF-8 character.',
|
|
282
|
+
source.bytes,
|
|
283
|
+
budget.bytes,
|
|
284
|
+
)
|
|
285
|
+
if (offset < window.offset) throw splits()
|
|
251
286
|
const bytes = window.bytes.subarray(offset - window.offset)
|
|
287
|
+
if (bytes.length && ((bytes[0] ?? 0) & 0xc0) === 0x80) throw splits()
|
|
252
288
|
let text = decode(utf8Page(bytes, 24_000))
|
|
253
289
|
if (text.length > 6000) {
|
|
254
290
|
let end = 6000
|
|
@@ -5,6 +5,7 @@ import type { ToolContext } from '../../types/tool/index.js'
|
|
|
5
5
|
import type { ToolFacts } from '../../types/tool/presentation.js'
|
|
6
6
|
import { defineTool } from '../defineTool.js'
|
|
7
7
|
import { resolveWithinAnyReal, toolRoots } from '../paths.js'
|
|
8
|
+
import { resolveRetainedOutput } from '../retained-output.js'
|
|
8
9
|
import { looksBinary } from '../text-window.js'
|
|
9
10
|
import { fingerprintContent } from './content-fingerprint.js'
|
|
10
11
|
import { type RenderedRead, renderCappedRead } from './read-render.js'
|
|
@@ -71,8 +72,12 @@ export const ReadFileTool = defineTool({
|
|
|
71
72
|
},
|
|
72
73
|
|
|
73
74
|
async execute(input, context) {
|
|
75
|
+
// This session's own retained tool output lives on the host, outside the
|
|
76
|
+
// roots and outside any sandbox. It is read here, as a plain window of text,
|
|
77
|
+
// because the result that named it told the model to.
|
|
78
|
+
const retained = await resolveRetainedOutput(context.retainedOutputDirectory, input.path)
|
|
74
79
|
// Sandbox-aware: route through sandbox.readFile() when available
|
|
75
|
-
if (context.sandbox) {
|
|
80
|
+
if (context.sandbox && retained === undefined) {
|
|
76
81
|
const buffer = await context.sandbox.readFile(input.path)
|
|
77
82
|
const binaryGuidance = describeStructuredBinaryRead(input.path, buffer)
|
|
78
83
|
if (binaryGuidance) {
|
|
@@ -112,7 +117,7 @@ export const ReadFileTool = defineTool({
|
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
const filePath = await resolveWithinAnyReal(toolRoots(context), input.path)
|
|
120
|
+
const filePath = retained ?? (await resolveWithinAnyReal(toolRoots(context), input.path))
|
|
116
121
|
// Refused on the size alone, before the file is in memory, when no window
|
|
117
122
|
// was named: the cap exists so a huge file is never loaded to be refused.
|
|
118
123
|
if (input.readRange === undefined && input.limit === undefined) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises'
|
|
2
|
+
import { dirname, extname, isAbsolute, relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
function inside(root: string, path: string): boolean {
|
|
5
|
+
const rel = relative(root, path)
|
|
6
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The real path of a retained tool-output file the model may read, or
|
|
11
|
+
* `undefined` when `candidate` is not one.
|
|
12
|
+
*
|
|
13
|
+
* Only this session's own retained directory counts. The file must be an
|
|
14
|
+
* absolute path to a regular `.txt` file that is not a symbolic link, has no
|
|
15
|
+
* other hard link, and whose real location is inside the directory's real
|
|
16
|
+
* location (so a link anywhere along the path, or `..`, takes it out). Nothing
|
|
17
|
+
* is created, and nothing but a read is offered.
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveRetainedOutput(
|
|
20
|
+
directory: string | undefined,
|
|
21
|
+
candidate: string | undefined,
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
if (!directory || !candidate || !isAbsolute(candidate) || candidate.includes('\0'))
|
|
24
|
+
return undefined
|
|
25
|
+
try {
|
|
26
|
+
const base = await realpath(directory)
|
|
27
|
+
const lexical = resolve(candidate)
|
|
28
|
+
if (extname(lexical).toLowerCase() !== '.txt') return undefined
|
|
29
|
+
if (!inside(resolve(directory), lexical) && !inside(base, lexical)) return undefined
|
|
30
|
+
const stat = await lstat(lexical)
|
|
31
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) return undefined
|
|
32
|
+
const real = await realpath(lexical)
|
|
33
|
+
if (!inside(base, real) || dirname(real) === real) return undefined
|
|
34
|
+
return real
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -5,6 +5,48 @@ import type { SessionEventListener } from '../session/events.js'
|
|
|
5
5
|
import type { AgentLifecycleListener } from './lifecycle-event.js'
|
|
6
6
|
import type { AgentTask, AgentTaskContext, AgentTaskState, SendMessageOptions } from './task.js'
|
|
7
7
|
|
|
8
|
+
/** Why a saved child could not be made continuable again. */
|
|
9
|
+
export type RestoreChildRefusal =
|
|
10
|
+
/** No project layout is known, so there is no saved log to read. */
|
|
11
|
+
| 'no-layout'
|
|
12
|
+
/** Nothing readable is saved under that id for this parent. */
|
|
13
|
+
| 'not-saved'
|
|
14
|
+
/** The saved record belongs to another parent session. */
|
|
15
|
+
| 'foreign'
|
|
16
|
+
/** The saved record carries no continuation profile, was isolated, or names an agent the host cannot rebuild. */
|
|
17
|
+
| 'not-resumable'
|
|
18
|
+
/** The child never recorded an end: its process died mid-turn. */
|
|
19
|
+
| 'unsettled'
|
|
20
|
+
/** The child was cancelled. */
|
|
21
|
+
| 'stopped'
|
|
22
|
+
/** This manager already holds a continuation for it that is still running. */
|
|
23
|
+
| 'running'
|
|
24
|
+
/** Too many continuations are retained to take another. */
|
|
25
|
+
| 'full'
|
|
26
|
+
|
|
27
|
+
export interface RestoreChildOptions {
|
|
28
|
+
/** The saved child's session id. */
|
|
29
|
+
readonly childSessionId: SessionId
|
|
30
|
+
/** The directory the continued child works in; the same one it ran in. */
|
|
31
|
+
readonly workingDirectory: string
|
|
32
|
+
/**
|
|
33
|
+
* Which registered agent the child continues as, given the profile the host
|
|
34
|
+
* recorded at launch. Return `undefined` when the host cannot rebuild it.
|
|
35
|
+
*/
|
|
36
|
+
readonly agentFor: (profile: Readonly<Record<string, string>>) => string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type RestoreChildResult =
|
|
40
|
+
| {
|
|
41
|
+
readonly status: 'restored'
|
|
42
|
+
readonly childSessionId: SessionId
|
|
43
|
+
/** The agent chosen by {@link RestoreChildOptions.agentFor}; pass it to `createTask` with `resumeSessionId`. */
|
|
44
|
+
readonly agentId: string
|
|
45
|
+
readonly profile: Readonly<Record<string, string>>
|
|
46
|
+
readonly description: string
|
|
47
|
+
}
|
|
48
|
+
| { readonly status: 'refused'; readonly reason: RestoreChildRefusal }
|
|
49
|
+
|
|
8
50
|
/**
|
|
9
51
|
* Agent task lifecycle contract — task creation, cancellation, messaging, and completion tracking.
|
|
10
52
|
* Concrete implementation: `AgentManager` in `manager/agent/lifecycle.ts`.
|
|
@@ -48,6 +90,18 @@ export interface AgentManagerContract {
|
|
|
48
90
|
on(listener: AgentLifecycleListener): void
|
|
49
91
|
off(listener: AgentLifecycleListener): void
|
|
50
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Rebuild the right to continue a child that finished in an earlier process
|
|
95
|
+
* from its saved log and meta document, under the invoking parent's scope.
|
|
96
|
+
* Optional: only a manager that can read saved children offers it. Grants
|
|
97
|
+
* nothing by itself; continuing still goes through `sendMessage` with
|
|
98
|
+
* `resumeSessionId`, with the invoking turn's authority and limits.
|
|
99
|
+
*/
|
|
100
|
+
restoreChildConversation?(
|
|
101
|
+
options: RestoreChildOptions,
|
|
102
|
+
context: AgentTaskContext,
|
|
103
|
+
): Promise<RestoreChildResult>
|
|
104
|
+
|
|
51
105
|
cleanup(): void
|
|
52
106
|
dispose(): void
|
|
53
107
|
}
|
|
@@ -5,6 +5,7 @@ import type { CancelCause } from '../session/cancel-cause.js'
|
|
|
5
5
|
import type { ChildSessionLifecycleEvent, SessionEventListener } from '../session/events.js'
|
|
6
6
|
import type { WorkspaceRef } from '../workspace/ref.js'
|
|
7
7
|
import type { AgentRuntimeContext, BaseAgentConfig, BaseAgentResult } from './base.js'
|
|
8
|
+
import type { RestoreChildOptions, RestoreChildResult } from './manager.js'
|
|
8
9
|
import type { AgentTaskState, ChildWorkspaceRequest } from './task.js'
|
|
9
10
|
|
|
10
11
|
export interface TaskHandle {
|
|
@@ -41,6 +42,15 @@ export interface CreateTaskOptions {
|
|
|
41
42
|
* and unchanged agent definition. A saved log alone grants no authority.
|
|
42
43
|
*/
|
|
43
44
|
readonly resumeSessionId?: SessionId
|
|
45
|
+
/**
|
|
46
|
+
* What a later process needs to rebuild this child, written to the child's
|
|
47
|
+
* saved meta document: at most 16 string entries, keys up to 64 and values up
|
|
48
|
+
* to 256 characters. The host defines the keys (which agent to rebuild,
|
|
49
|
+
* which model) and reads them back from `restoreChild`. Never put a secret
|
|
50
|
+
* here; it lands on disk beside the log. Absent, the child cannot be
|
|
51
|
+
* continued from disk after its process ends.
|
|
52
|
+
*/
|
|
53
|
+
readonly continuationProfile?: Readonly<Record<string, string>>
|
|
44
54
|
/** Per-task filesystem choice, forwarded to the local manager at admission. */
|
|
45
55
|
readonly workspace?: ChildWorkspaceRequest
|
|
46
56
|
/**
|
|
@@ -156,6 +166,17 @@ export interface TaskScheduler {
|
|
|
156
166
|
|
|
157
167
|
continueTask(taskId: TaskId, message: string): Promise<void>
|
|
158
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Make a child saved by an earlier process continuable again, from its saved
|
|
171
|
+
* log and meta document, under this scheduler's parent scope. OPTIONAL: only
|
|
172
|
+
* a scheduler that can read saved children offers it. It runs nothing; the
|
|
173
|
+
* caller then continues the child with `createTask({ resumeSessionId })`, so
|
|
174
|
+
* the invoking turn's authority and limits apply. See
|
|
175
|
+
* {@link CreateTaskOptions.continuationProfile} for what must have been
|
|
176
|
+
* recorded at launch.
|
|
177
|
+
*/
|
|
178
|
+
restoreChild?(options: RestoreChildOptions): Promise<RestoreChildResult>
|
|
179
|
+
|
|
159
180
|
/**
|
|
160
181
|
* Stop one task. A cause is relative to the child: a blocking delegation
|
|
161
182
|
* abandoned by the turn that launched it is cancelled by its `parent`.
|
package/src/types/agent/task.ts
CHANGED
|
@@ -238,6 +238,8 @@ export interface AgentTask {
|
|
|
238
238
|
export interface SendMessageOptions {
|
|
239
239
|
/** See {@link import('./scheduler.js').CreateTaskOptions.resumeSessionId}. */
|
|
240
240
|
readonly resumeSessionId?: SessionId
|
|
241
|
+
/** See {@link import('./scheduler.js').CreateTaskOptions.continuationProfile}. */
|
|
242
|
+
readonly continuationProfile?: Readonly<Record<string, string>>
|
|
241
243
|
/** Explicit child filesystem policy; omitted preserves legacy backend behavior. */
|
|
242
244
|
readonly workspace?: ChildWorkspaceRequest
|
|
243
245
|
/**
|
|
@@ -1039,6 +1039,22 @@ export const ChildSessionMetaSchema = z
|
|
|
1039
1039
|
status: turnExecutionStatus,
|
|
1040
1040
|
createdAt: isoUtc,
|
|
1041
1041
|
endedAt: isoUtc.optional(),
|
|
1042
|
+
/**
|
|
1043
|
+
* What a later process needs to continue this child from its saved log:
|
|
1044
|
+
* the filesystem policy it ran under and a small host-defined profile
|
|
1045
|
+
* (which agent to rebuild, which model). Absent on children saved before
|
|
1046
|
+
* this field existed and on children whose host recorded no profile; those
|
|
1047
|
+
* cannot be continued from disk.
|
|
1048
|
+
*/
|
|
1049
|
+
continuation: z
|
|
1050
|
+
.object({
|
|
1051
|
+
workspace: z.enum(['shared', 'isolated']),
|
|
1052
|
+
profile: z
|
|
1053
|
+
.record(z.string().min(1).max(64), z.string().max(256))
|
|
1054
|
+
.refine((value) => Object.keys(value).length <= 16, 'At most 16 profile entries'),
|
|
1055
|
+
})
|
|
1056
|
+
.strict()
|
|
1057
|
+
.optional(),
|
|
1042
1058
|
})
|
|
1043
1059
|
.strict()
|
|
1044
1060
|
export type ChildSessionMeta = z.infer<typeof ChildSessionMetaSchema>
|
package/src/types/tool/index.ts
CHANGED
|
@@ -458,6 +458,14 @@ export interface ToolContext {
|
|
|
458
458
|
* directory; an absolute path inside any of these is accepted.
|
|
459
459
|
*/
|
|
460
460
|
additionalDirectories?: readonly string[]
|
|
461
|
+
/**
|
|
462
|
+
* Absolute path of the directory holding THIS session's retained tool
|
|
463
|
+
* output (the files an oversized result was saved to). The `read` tool may
|
|
464
|
+
* open a regular file in it, and only that, without the path being a
|
|
465
|
+
* reviewed or added directory: the model was already shown the path in the
|
|
466
|
+
* result it came from. Set by the executor whenever the turn retains output.
|
|
467
|
+
*/
|
|
468
|
+
retainedOutputDirectory?: string
|
|
461
469
|
/**
|
|
462
470
|
* Absolute paths outside the working directory and the added directories
|
|
463
471
|
* that THIS call was approved to reach.
|