@animalabs/agent-framework 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent.d.ts +10 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +39 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +36 -6
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +451 -83
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +55 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +41 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +1 -1
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +69 -0
- package/dist/src/modules/history/index.d.ts.map +1 -0
- package/dist/src/modules/history/index.js +519 -0
- package/dist/src/modules/history/index.js.map +1 -0
- package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
- package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
- package/dist/src/modules/history/search-regex-worker.js +57 -0
- package/dist/src/modules/history/search-regex-worker.js.map +1 -0
- package/dist/src/modules/index.d.ts +1 -0
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -0
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +42 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +5 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/tool-image-ledger.d.ts +175 -0
- package/dist/src/tool-image-ledger.d.ts.map +1 -0
- package/dist/src/tool-image-ledger.js +257 -0
- package/dist/src/tool-image-ledger.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +15 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +16 -9
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +10 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +4 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/event-gate.test.js +84 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/history-module.test.d.ts +2 -0
- package/dist/test/history-module.test.d.ts.map +1 -0
- package/dist/test/history-module.test.js +449 -0
- package/dist/test/history-module.test.js.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
- package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +39 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -1
- package/dist/test/puppet-tool-call.test.js +114 -1
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
- package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
- package/dist/test/puppet-turn-reservation.test.js +139 -0
- package/dist/test/puppet-turn-reservation.test.js.map +1 -0
- package/dist/test/save-recent-image.test.d.ts +26 -0
- package/dist/test/save-recent-image.test.d.ts.map +1 -0
- package/dist/test/save-recent-image.test.js +736 -0
- package/dist/test/save-recent-image.test.js.map +1 -0
- package/dist/test/tool-result-spill.test.js +114 -0
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +17 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/workspace-read-text.test.d.ts +2 -0
- package/dist/test/workspace-read-text.test.d.ts.map +1 -0
- package/dist/test/workspace-read-text.test.js +101 -0
- package/dist/test/workspace-read-text.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +41 -0
- package/src/framework.ts +519 -95
- package/src/gate/event-gate.ts +71 -3
- package/src/gate/index.ts +1 -1
- package/src/index.ts +1 -1
- package/src/modules/history/index.ts +613 -0
- package/src/modules/history/search-regex-worker.ts +76 -0
- package/src/modules/index.ts +2 -0
- package/src/modules/workspace/index.ts +42 -1
- package/src/modules/workspace/types.ts +5 -0
- package/src/tool-image-ledger.ts +334 -0
- package/src/tool-result-history.ts +30 -9
- package/src/types/agent.ts +10 -0
- package/src/types/trace.ts +4 -0
package/src/modules/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type { ApiEvent } from './api/index.js';
|
|
|
8
8
|
export { HealthModule } from './health/index.js';
|
|
9
9
|
export type { HealthModuleConfig } from './health/index.js';
|
|
10
10
|
|
|
11
|
+
export { HistoryModule } from './history/index.js';
|
|
12
|
+
|
|
11
13
|
export { WorkspaceModule, WorkspaceReadError } from './workspace/index.js';
|
|
12
14
|
export type { WorkspaceReadErrorCode, WorkspaceReadStage, WorkspaceDiskReadResult, ReadFileFromDiskOptions } from './workspace/index.js';
|
|
13
15
|
export type {
|
|
@@ -932,13 +932,19 @@ export class WorkspaceModule implements Module {
|
|
|
932
932
|
return [
|
|
933
933
|
{
|
|
934
934
|
name: 'read',
|
|
935
|
-
description: 'Read a file from the workspace. Returns content with line numbers.
|
|
935
|
+
description: 'Read a file from the workspace. Returns content with line numbers. '
|
|
936
|
+
+ 'Reads at most 2000 lines per call by default — use offset/limit to page through larger files. '
|
|
937
|
+
+ 'For long lines or spill files, use offsetChars/limitChars instead: returns raw text without '
|
|
938
|
+
+ 'line numbers, with nextOffsetChars (null at EOF). Keep limitChars when continuing. '
|
|
939
|
+
+ 'Character offsets count UTF-16 code units; do not mix character and line parameters.',
|
|
936
940
|
inputSchema: {
|
|
937
941
|
type: 'object' as const,
|
|
938
942
|
properties: {
|
|
939
943
|
path: { type: 'string', description: 'File path (mount-prefixed, e.g., "project/src/main.ts")' },
|
|
940
944
|
offset: { type: 'number', description: 'Starting line number (1-indexed)' },
|
|
941
945
|
limit: { type: 'number', description: 'Maximum number of lines to return (default 2000)' },
|
|
946
|
+
offsetChars: { type: 'integer', description: 'Character paging: zero-based UTF-16 offset (default 0); use nextOffsetChars to continue' },
|
|
947
|
+
limitChars: { type: 'integer', description: 'Character paging: code units to return (default 2000), plus at most one to keep a surrogate pair intact. Use the limit suggested by a spill notice.' },
|
|
942
948
|
},
|
|
943
949
|
required: ['path'],
|
|
944
950
|
},
|
|
@@ -1741,6 +1747,19 @@ export class WorkspaceModule implements Module {
|
|
|
1741
1747
|
// ==========================================================================
|
|
1742
1748
|
|
|
1743
1749
|
private async handleRead(input: ReadInput): Promise<ToolResult> {
|
|
1750
|
+
const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
|
|
1751
|
+
const offsetChars = input.offsetChars ?? 0;
|
|
1752
|
+
const limitChars = input.limitChars ?? 2000;
|
|
1753
|
+
if (characterPaging) {
|
|
1754
|
+
if (input.offset !== undefined || input.limit !== undefined) {
|
|
1755
|
+
return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
|
|
1756
|
+
}
|
|
1757
|
+
if (!Number.isSafeInteger(offsetChars) || offsetChars < 0
|
|
1758
|
+
|| !Number.isSafeInteger(limitChars) || limitChars < 1
|
|
1759
|
+
|| input.offsetChars === null || input.limitChars === null) {
|
|
1760
|
+
return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1744
1763
|
const { mount, relativePath } = this.parsePath(input.path);
|
|
1745
1764
|
const store = this.getStore();
|
|
1746
1765
|
|
|
@@ -1757,6 +1776,28 @@ export class WorkspaceModule implements Module {
|
|
|
1757
1776
|
}
|
|
1758
1777
|
|
|
1759
1778
|
const content = blob.toString('utf-8');
|
|
1779
|
+
if (characterPaging) {
|
|
1780
|
+
const start = Math.min(offsetChars, content.length);
|
|
1781
|
+
const splitsPair = (at: number): boolean =>
|
|
1782
|
+
at > 0 && at < content.length
|
|
1783
|
+
&& content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
|
|
1784
|
+
&& content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
|
|
1785
|
+
if (splitsPair(start)) {
|
|
1786
|
+
return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
|
|
1787
|
+
}
|
|
1788
|
+
let end = start + Math.min(limitChars, content.length - start);
|
|
1789
|
+
if (splitsPair(end)) end++;
|
|
1790
|
+
return {
|
|
1791
|
+
success: true,
|
|
1792
|
+
data: {
|
|
1793
|
+
path: input.path,
|
|
1794
|
+
totalChars: content.length,
|
|
1795
|
+
offsetChars: start,
|
|
1796
|
+
nextOffsetChars: end < content.length ? end : null,
|
|
1797
|
+
content: content.slice(start, end),
|
|
1798
|
+
},
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1760
1801
|
const lines = content.split('\n');
|
|
1761
1802
|
|
|
1762
1803
|
// Apply offset/limit. An unlimited read of a large file would inject the
|
|
@@ -133,6 +133,11 @@ export interface ReadInput {
|
|
|
133
133
|
offset?: number;
|
|
134
134
|
/** Number of lines to read (optional) */
|
|
135
135
|
limit?: number;
|
|
136
|
+
/** Zero-based UTF-16 offset; selects character paging. Cannot mix with line offset/limit. */
|
|
137
|
+
offsetChars?: number;
|
|
138
|
+
/** Character page size in UTF-16 code units (default 2000); selects character paging.
|
|
139
|
+
* May include one extra code unit to keep a surrogate pair intact. */
|
|
140
|
+
limitChars?: number;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
export interface ReadImageInput {
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent retention of tool-result images, with provenance (issue #104).
|
|
3
|
+
*
|
|
4
|
+
* History keeps a compact text placeholder for every image a tool returns —
|
|
5
|
+
* the bytes only ever ride the live wire copy of that one turn. That policy
|
|
6
|
+
* is right (megabytes of base64 in the persisted window would be sliced,
|
|
7
|
+
* compressed, and replayed forever), but it left `save_recent_image` with no
|
|
8
|
+
* way to reach an image the resident had just seen: the scanner walked past
|
|
9
|
+
* the byteless placeholder and quietly saved whichever OLDER attachment came
|
|
10
|
+
* next, under the filename the resident chose for the new one.
|
|
11
|
+
*
|
|
12
|
+
* This ledger is the missing half. At ingestion every tool-result image is
|
|
13
|
+
* retained here under a stable per-agent ref (`img_k7x3q2_7`) bound to the
|
|
14
|
+
* exact tool call and block it came from, with its digest. The ref carries a
|
|
15
|
+
* per-ledger nonce: ledgers live and die with the process, but the persisted
|
|
16
|
+
* store keeps yesterday's placeholders visible in context, so a bare sequence
|
|
17
|
+
* number would let a stale `img_7` resolve to whatever this process minted
|
|
18
|
+
* seventh. A stale ref must resolve `unknown`, never to other bytes.
|
|
19
|
+
*
|
|
20
|
+
* The history placeholder carries the ref, so the ordered inventory
|
|
21
|
+
* `save_recent_image` walks can
|
|
22
|
+
* include tool images at their true position — and when the bytes behind a
|
|
23
|
+
* ref are gone (bounded eviction, or a restart: the ledger is in-memory by
|
|
24
|
+
* design, so it never adds to blob growth), the save fails AT THAT INDEX with
|
|
25
|
+
* a specific error instead of sliding to an older image. Silent substitution
|
|
26
|
+
* of bytes from another surface is the failure this exists to end.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
30
|
+
import { safeSlice } from './safe-slice.js';
|
|
31
|
+
|
|
32
|
+
export interface RetainedToolImage {
|
|
33
|
+
/** Stable per-ledger handle (`img_<nonce>_<seq>`), minted once per (toolCallId, blockIndex). */
|
|
34
|
+
ref: string;
|
|
35
|
+
toolCallId: string;
|
|
36
|
+
toolName: string;
|
|
37
|
+
/** Position of the image block inside the tool result's content array. */
|
|
38
|
+
blockIndex: number;
|
|
39
|
+
mediaType: string;
|
|
40
|
+
byteSize: number;
|
|
41
|
+
sha256: string;
|
|
42
|
+
retainedAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A retained image whose bytes are still held. */
|
|
46
|
+
export interface RetainedToolImageWithData extends RetainedToolImage {
|
|
47
|
+
/** Base64 payload, exactly as the tool returned it. */
|
|
48
|
+
data: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ToolImageLookup =
|
|
52
|
+
| { status: 'retained'; image: RetainedToolImageWithData }
|
|
53
|
+
/** The ref was issued by this ledger but its bytes were evicted (bounded budget). */
|
|
54
|
+
| { status: 'evicted'; image: RetainedToolImage }
|
|
55
|
+
/** Never issued by this ledger — a typo, or a ref minted before a restart. */
|
|
56
|
+
| { status: 'unknown' };
|
|
57
|
+
|
|
58
|
+
export interface ToolImageLedgerOptions {
|
|
59
|
+
/** Total base64 chars held across retained images (≈ bytes × 4/3). */
|
|
60
|
+
maxChars?: number;
|
|
61
|
+
/** Maximum number of images whose bytes are held. */
|
|
62
|
+
maxEntries?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 96 MB of base64 ≈ 72 MB of image bytes — a few dozen world snapshots. */
|
|
66
|
+
export const DEFAULT_TOOL_IMAGE_LEDGER_MAX_CHARS = 96 * 1024 * 1024;
|
|
67
|
+
export const DEFAULT_TOOL_IMAGE_LEDGER_MAX_ENTRIES = 64;
|
|
68
|
+
|
|
69
|
+
export class ToolImageLedger {
|
|
70
|
+
/** Namespace for every ref this ledger mints; a fresh ledger (new process,
|
|
71
|
+
* agent re-registration) never shares one with its predecessors. */
|
|
72
|
+
readonly nonce: string = mintLedgerNonce();
|
|
73
|
+
private nextSeq = 1;
|
|
74
|
+
/** Insertion-ordered (oldest first) — eviction pops from the front. */
|
|
75
|
+
private readonly retained = new Map<string, RetainedToolImageWithData>();
|
|
76
|
+
/** Provenance for every ref ever issued, bytes or not (bounded separately). */
|
|
77
|
+
private readonly issued = new Map<string, RetainedToolImage>();
|
|
78
|
+
private readonly refByKey = new Map<string, string>();
|
|
79
|
+
private heldChars = 0;
|
|
80
|
+
private readonly maxChars: number;
|
|
81
|
+
private readonly maxEntries: number;
|
|
82
|
+
|
|
83
|
+
constructor(options: ToolImageLedgerOptions = {}) {
|
|
84
|
+
this.maxChars = options.maxChars ?? DEFAULT_TOOL_IMAGE_LEDGER_MAX_CHARS;
|
|
85
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_TOOL_IMAGE_LEDGER_MAX_ENTRIES;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Retain one image block from a tool result. Idempotent per
|
|
90
|
+
* (toolCallId, blockIndex) for as long as that block's provenance record
|
|
91
|
+
* is held (bounded — see trimProvenance): within that window the same
|
|
92
|
+
* block always yields the same ref, so a result serialized twice (or
|
|
93
|
+
* retained early in a tool batch, then serialized at commit) agrees with
|
|
94
|
+
* itself. A block re-serialized after its record aged out mints a fresh
|
|
95
|
+
* ref; the old one resolves `evicted`/`unknown`, never to other bytes.
|
|
96
|
+
*/
|
|
97
|
+
retain(source: {
|
|
98
|
+
toolCallId: string;
|
|
99
|
+
toolName: string;
|
|
100
|
+
blockIndex: number;
|
|
101
|
+
data: string;
|
|
102
|
+
mediaType: string;
|
|
103
|
+
}): RetainedToolImage {
|
|
104
|
+
const key = `${source.toolCallId}:${source.blockIndex}`;
|
|
105
|
+
const existingRef = this.refByKey.get(key);
|
|
106
|
+
if (existingRef !== undefined) {
|
|
107
|
+
const known = this.issued.get(existingRef);
|
|
108
|
+
if (known) return known;
|
|
109
|
+
}
|
|
110
|
+
const ref = `img_${this.nonce}_${this.nextSeq++}`;
|
|
111
|
+
// Digest without materializing the whole decoded image: a 96MB base64
|
|
112
|
+
// field would otherwise transiently allocate ~72MB more and the ledger
|
|
113
|
+
// would be the one asking for it. Chunked decode keeps the peak small.
|
|
114
|
+
const { byteSize, sha256 } = digestBase64(source.data);
|
|
115
|
+
const image: RetainedToolImageWithData = {
|
|
116
|
+
ref,
|
|
117
|
+
toolCallId: source.toolCallId,
|
|
118
|
+
toolName: source.toolName,
|
|
119
|
+
blockIndex: source.blockIndex,
|
|
120
|
+
// Tools send mime types unvalidated; the placeholder grammar is
|
|
121
|
+
// lowercase, so normalize here and at format time (same rule) or an
|
|
122
|
+
// `Image/PNG` would write a placeholder that never re-parses — an
|
|
123
|
+
// invisible slot, i.e. a silent index slide.
|
|
124
|
+
mediaType: normalizeMediaType(source.mediaType),
|
|
125
|
+
byteSize,
|
|
126
|
+
sha256,
|
|
127
|
+
retainedAt: Date.now(),
|
|
128
|
+
data: source.data,
|
|
129
|
+
};
|
|
130
|
+
const { data: _data, ...provenance } = image;
|
|
131
|
+
this.refByKey.set(key, ref);
|
|
132
|
+
this.issued.set(ref, provenance);
|
|
133
|
+
// A single image over the whole budget is issued (so the placeholder is
|
|
134
|
+
// honest about its provenance) but never held.
|
|
135
|
+
if (source.data.length <= this.maxChars) {
|
|
136
|
+
this.retained.set(ref, image);
|
|
137
|
+
this.heldChars += source.data.length;
|
|
138
|
+
this.evictOverBudget();
|
|
139
|
+
}
|
|
140
|
+
this.trimProvenance();
|
|
141
|
+
return provenance;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lookup(ref: string): ToolImageLookup {
|
|
145
|
+
const held = this.retained.get(ref);
|
|
146
|
+
if (held) return { status: 'retained', image: held };
|
|
147
|
+
const known = this.issued.get(ref);
|
|
148
|
+
if (known) return { status: 'evicted', image: known };
|
|
149
|
+
return { status: 'unknown' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ref already minted for this block, if any (no retention side effect). */
|
|
153
|
+
refFor(toolCallId: string, blockIndex: number): string | undefined {
|
|
154
|
+
return this.refByKey.get(`${toolCallId}:${blockIndex}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Count of images whose bytes are currently held. */
|
|
158
|
+
get size(): number {
|
|
159
|
+
return this.retained.size;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private evictOverBudget(): void {
|
|
163
|
+
for (const [ref, image] of this.retained) {
|
|
164
|
+
if (this.retained.size <= this.maxEntries && this.heldChars <= this.maxChars) break;
|
|
165
|
+
this.retained.delete(ref);
|
|
166
|
+
this.heldChars -= image.data.length;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Provenance for evicted refs is kept for a while so the error can name
|
|
171
|
+
* the tool call; bounded so a long-lived agent can't grow it forever. */
|
|
172
|
+
private trimProvenance(): void {
|
|
173
|
+
const keep = Math.max(this.maxEntries * 4, 256);
|
|
174
|
+
while (this.issued.size > keep) {
|
|
175
|
+
const oldest = this.issued.keys().next().value;
|
|
176
|
+
if (oldest === undefined) break;
|
|
177
|
+
const image = this.issued.get(oldest);
|
|
178
|
+
this.issued.delete(oldest);
|
|
179
|
+
if (image) this.refByKey.delete(`${image.toolCallId}:${image.blockIndex}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Six random base36 chars — one per ledger lifetime, embedded in every ref. */
|
|
185
|
+
function mintLedgerNonce(): string {
|
|
186
|
+
return randomBytes(4).readUInt32BE(0).toString(36).padStart(6, '0').slice(-6);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Grammar of a ref this module mints: `img_<ledger nonce>_<sequence>`. */
|
|
190
|
+
export const TOOL_IMAGE_REF_RE = /^img_[0-9a-z]+_\d+$/;
|
|
191
|
+
|
|
192
|
+
/** RFC 6838 type/subtype token characters (lowercased); no parameters. */
|
|
193
|
+
const MEDIA_TYPE_ESSENCE_RE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/;
|
|
194
|
+
export const FALLBACK_MEDIA_TYPE = 'application/octet-stream';
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The one spelling the placeholder grammar accepts: trimmed, lowercased, the
|
|
198
|
+
* `type/subtype` essence only (parameters such as `; charset=binary` are
|
|
199
|
+
* dropped), and — because `mimeType` arrives from external MCPL servers
|
|
200
|
+
* unvalidated — anything that still is not a well-formed type/subtype falls
|
|
201
|
+
* back to `application/octet-stream`. The invariant this buys: the
|
|
202
|
+
* serializer can NEVER write a placeholder that does not re-parse, so a
|
|
203
|
+
* strange mime can never produce an invisible slot (a silent index slide).
|
|
204
|
+
*/
|
|
205
|
+
export function normalizeMediaType(mediaType: string): string {
|
|
206
|
+
const essence = mediaType.split(';', 1)[0]!.trim().toLowerCase();
|
|
207
|
+
return MEDIA_TYPE_ESSENCE_RE.test(essence) ? essence : FALLBACK_MEDIA_TYPE;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** History placeholder for a retained tool image: `[image: image/png, ~691KB, ref img_7]`. */
|
|
211
|
+
export function formatToolImagePlaceholder(mediaType: string, sizeLabel: string, ref: string | null): string {
|
|
212
|
+
const mime = normalizeMediaType(mediaType);
|
|
213
|
+
return ref === null
|
|
214
|
+
? `[image: ${mime}, ${sizeLabel}]`
|
|
215
|
+
: `[image: ${mime}, ${sizeLabel}, ref ${ref}]`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ParsedImagePlaceholder =
|
|
219
|
+
/** An inline tool image the serializer replaced with a placeholder. */
|
|
220
|
+
| {
|
|
221
|
+
kind: 'inline';
|
|
222
|
+
mediaType: string;
|
|
223
|
+
sizeLabel: string;
|
|
224
|
+
/** null for placeholders written before retention existed (unsaveable). */
|
|
225
|
+
ref: string | null;
|
|
226
|
+
/** Character offset in the scanned text — for ordering within a block. */
|
|
227
|
+
offset: number;
|
|
228
|
+
/** The placeholder exactly as it appears in the text. */
|
|
229
|
+
text: string;
|
|
230
|
+
}
|
|
231
|
+
/** An RFC-005 reference stub whose testimony says image — bytes live
|
|
232
|
+
* behind fetch_reference, never in this ledger. Occupies a slot so the
|
|
233
|
+
* index cannot slide past it. */
|
|
234
|
+
| {
|
|
235
|
+
kind: 'reference';
|
|
236
|
+
refId: string;
|
|
237
|
+
/** Mime sniffed from the stub text; null when the stub names none. */
|
|
238
|
+
mediaType: string | null;
|
|
239
|
+
offset: number;
|
|
240
|
+
/** The stub line exactly as it appears in the text. */
|
|
241
|
+
text: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const PLACEHOLDER_RE = /\[image: ([a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+), (~?[\d.]+(?:B|KB|MB))(?:, ref (img_[0-9a-z]+_\d+))?\]/g;
|
|
245
|
+
/** `[ref_1_ab3d] cam.png — image/png, 1.2MB claimed — from tool result — …` (one line). */
|
|
246
|
+
const REFERENCE_STUB_RE = /^\[(ref_[0-9a-z]+_[0-9a-z]+)\] ([^\n]*)$/gm;
|
|
247
|
+
const IMAGE_MIME_RE = /\bimage\/[a-z0-9!#$&^_.+-]+/i;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Every image slot in a stored tool-result string, in text order: inline
|
|
251
|
+
* placeholders and image-typed reference stubs. Text-derived and therefore
|
|
252
|
+
* untrusted — a tool result can quote someone else's placeholder verbatim —
|
|
253
|
+
* so the caller MUST cross-check a resolved ref's provenance against the
|
|
254
|
+
* tool_result block it was found in before handing out bytes.
|
|
255
|
+
*/
|
|
256
|
+
export function parseImagePlaceholders(text: string): ParsedImagePlaceholder[] {
|
|
257
|
+
const out: ParsedImagePlaceholder[] = [];
|
|
258
|
+
for (const m of text.matchAll(PLACEHOLDER_RE)) {
|
|
259
|
+
out.push({ kind: 'inline', mediaType: m[1]!, sizeLabel: m[2]!, ref: m[3] ?? null, offset: m.index ?? 0, text: m[0] });
|
|
260
|
+
}
|
|
261
|
+
for (const m of text.matchAll(REFERENCE_STUB_RE)) {
|
|
262
|
+
const mime = m[2]!.match(IMAGE_MIME_RE);
|
|
263
|
+
out.push({ kind: 'reference', refId: m[1]!, mediaType: mime ? mime[0].toLowerCase() : null, offset: m.index ?? 0, text: m[0] });
|
|
264
|
+
}
|
|
265
|
+
out.sort((a, b) => a.offset - b.offset);
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Cut `text` to at most `cap` chars WITHOUT losing or splitting an image
|
|
271
|
+
* slot. The stored tool_result text is the only place the inventory
|
|
272
|
+
* discovers tool images, while the live wire still delivers every image
|
|
273
|
+
* regardless of text length — so a slot dropped by truncation is an image
|
|
274
|
+
* the resident saw that the index silently slides past (the #104 failure,
|
|
275
|
+
* one representation over). Returns the head to keep and the slots that
|
|
276
|
+
* fell at or past the cut, to be re-appended verbatim after the truncation
|
|
277
|
+
* notice. A slot straddling the cut moves the cut back to its start, so the
|
|
278
|
+
* head never contains a partial slot (a partial reference-stub line would
|
|
279
|
+
* otherwise still re-parse and be counted twice).
|
|
280
|
+
*/
|
|
281
|
+
export function splitPreservingImageSlots(text: string, cap: number): { head: string; tail: string[] } {
|
|
282
|
+
const slots = parseImagePlaceholders(text);
|
|
283
|
+
let cutAt = cap;
|
|
284
|
+
for (const slot of slots) {
|
|
285
|
+
if (slot.offset < cutAt && slot.offset + slot.text.length > cutAt) cutAt = slot.offset;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
head: safeSlice(text, 0, cutAt),
|
|
289
|
+
tail: slots.filter((slot) => slot.offset >= cutAt).map((slot) => slot.text),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Text to append after a truncation notice so the preserved slots stay
|
|
294
|
+
* parseable (each reference stub on its own line). Empty when none. */
|
|
295
|
+
export function formatPreservedImageSlots(tail: string[]): string {
|
|
296
|
+
if (tail.length === 0) return '';
|
|
297
|
+
return `\n[${tail.length} image slot(s) fell past the cut; kept here in order so save_recent_image still counts them:]\n`
|
|
298
|
+
+ tail.join('\n');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function sha256Hex(bytes: Buffer): string {
|
|
302
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Only the base64 alphabet, with nothing but trailing `=` padding after it. */
|
|
306
|
+
function isCleanBase64(data: string): boolean {
|
|
307
|
+
const m = /[^A-Za-z0-9+/]/.exec(data);
|
|
308
|
+
if (!m) return true;
|
|
309
|
+
const rest = data.length - m.index;
|
|
310
|
+
return rest <= 2 && /^=+$/.test(data.slice(m.index));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Byte length and SHA-256 of a base64 payload, decoded in 4-char-aligned
|
|
315
|
+
* chunks so the peak allocation stays at one chunk rather than the whole
|
|
316
|
+
* image. Falls back to a whole decode when the string carries whitespace
|
|
317
|
+
* or embedded padding (chunk alignment would no longer be trustworthy) —
|
|
318
|
+
* the digest must describe exactly the bytes a later whole decode writes.
|
|
319
|
+
*/
|
|
320
|
+
function digestBase64(data: string): { byteSize: number; sha256: string } {
|
|
321
|
+
if (!isCleanBase64(data)) {
|
|
322
|
+
const bytes = Buffer.from(data, 'base64');
|
|
323
|
+
return { byteSize: bytes.byteLength, sha256: sha256Hex(bytes) };
|
|
324
|
+
}
|
|
325
|
+
const CHUNK = 1 << 20; // multiple of 4
|
|
326
|
+
const hash = createHash('sha256');
|
|
327
|
+
let byteSize = 0;
|
|
328
|
+
for (let i = 0; i < data.length; i += CHUNK) {
|
|
329
|
+
const bytes = Buffer.from(data.slice(i, i + CHUNK), 'base64');
|
|
330
|
+
hash.update(bytes);
|
|
331
|
+
byteSize += bytes.byteLength;
|
|
332
|
+
}
|
|
333
|
+
return { byteSize, sha256: hash.digest('hex') };
|
|
334
|
+
}
|
|
@@ -14,8 +14,18 @@
|
|
|
14
14
|
* For anything else it falls back to JSON.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { safeSlice } from './safe-slice.js';
|
|
18
17
|
import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js';
|
|
18
|
+
import { formatPreservedImageSlots, formatToolImagePlaceholder, splitPreservingImageSlots } from './tool-image-ledger.js';
|
|
19
|
+
|
|
20
|
+
export interface HistorySerializeOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Called for every image block the serializer replaces with a placeholder
|
|
23
|
+
* (issue #104). Returns the retention ref to embed in the placeholder —
|
|
24
|
+
* `[image: image/png, ~691KB, ref img_7]` — so the image keeps a saveable
|
|
25
|
+
* handle in history; null leaves the legacy ref-less form.
|
|
26
|
+
*/
|
|
27
|
+
imageRef?: (blockIndex: number, image: { data: string; mimeType: string }) => string | null;
|
|
28
|
+
}
|
|
19
29
|
|
|
20
30
|
/**
|
|
21
31
|
* House-safe default inline cap (chars) for tool results, error results, and
|
|
@@ -36,17 +46,24 @@ import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from
|
|
|
36
46
|
*/
|
|
37
47
|
export const DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS = 24000;
|
|
38
48
|
|
|
39
|
-
export function toolResultDataToHistoryString(
|
|
40
|
-
|
|
49
|
+
export function toolResultDataToHistoryString(
|
|
50
|
+
data: unknown,
|
|
51
|
+
maxChars?: number,
|
|
52
|
+
options?: HistorySerializeOptions,
|
|
53
|
+
): string {
|
|
54
|
+
const fromArray = tryHistoryStringFromContentArray(data, options);
|
|
41
55
|
const str = fromArray ?? JSON.stringify(data);
|
|
42
56
|
return maxChars ? truncateForHistory(str, maxChars) : str;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
/** Bounded copy of an arbitrary string with the standard truncation notice.
|
|
59
|
+
/** Bounded copy of an arbitrary string with the standard truncation notice.
|
|
60
|
+
* Image slots past the cut are re-appended, never dropped (issue #104). */
|
|
46
61
|
export function truncateForHistory(str: string, maxChars: number): string {
|
|
47
62
|
if (str.length <= maxChars) return str;
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
const { head, tail } = splitPreservingImageSlots(str, maxChars);
|
|
64
|
+
return head
|
|
65
|
+
+ '\n\n[truncated — original was ' + str.length + ' chars]'
|
|
66
|
+
+ formatPreservedImageSlots(tail);
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
/**
|
|
@@ -54,10 +71,10 @@ export function truncateForHistory(str: string, maxChars: number): string {
|
|
|
54
71
|
* recognize, return a history-safe string (images → placeholders). Otherwise
|
|
55
72
|
* return `null` to defer to the caller's fallback (JSON, usually).
|
|
56
73
|
*/
|
|
57
|
-
function tryHistoryStringFromContentArray(data: unknown): string | null {
|
|
74
|
+
function tryHistoryStringFromContentArray(data: unknown, options?: HistorySerializeOptions): string | null {
|
|
58
75
|
if (!Array.isArray(data)) return null;
|
|
59
76
|
const parts: string[] = [];
|
|
60
|
-
for (const raw of data) {
|
|
77
|
+
for (const [blockIndex, raw] of data.entries()) {
|
|
61
78
|
if (!raw || typeof raw !== 'object') return null;
|
|
62
79
|
const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
|
|
63
80
|
if (b.type === 'text' && typeof b.text === 'string') {
|
|
@@ -69,10 +86,14 @@ function tryHistoryStringFromContentArray(data: unknown): string | null {
|
|
|
69
86
|
} else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
|
|
70
87
|
// Decoded byte estimate from base64 length (3/4 ratio, rounded).
|
|
71
88
|
const approxBytes = Math.floor(b.data.length * 3 / 4);
|
|
72
|
-
|
|
89
|
+
const ref = options?.imageRef?.(blockIndex, { data: b.data, mimeType: b.mimeType }) ?? null;
|
|
90
|
+
parts.push(formatToolImagePlaceholder(b.mimeType, formatSize(approxBytes), ref));
|
|
73
91
|
} else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof (b as { uri?: unknown }).uri === 'string')) {
|
|
74
92
|
// RFC-005 reference block: a bounded stub, never the raw block JSON
|
|
75
93
|
// (which used to inline the URI — and the whole array — into history).
|
|
94
|
+
// Image-typed stubs still occupy a slot in save_recent_image's
|
|
95
|
+
// inventory (a failing one — bytes live behind fetch_reference), so
|
|
96
|
+
// the index never slides past an image the resident saw.
|
|
76
97
|
const stub = referenceStubOrNull(b, 'from tool result');
|
|
77
98
|
if (stub === null) return null;
|
|
78
99
|
parts.push(stub);
|
package/src/types/agent.ts
CHANGED
|
@@ -326,4 +326,14 @@ export interface InferenceRequest {
|
|
|
326
326
|
* so a household ledger can say who woke whom; carries no content.
|
|
327
327
|
*/
|
|
328
328
|
counterparty?: string;
|
|
329
|
+
/**
|
|
330
|
+
* Where the wake came from, for telemetry ONLY — never a speech locus.
|
|
331
|
+
* Set by the EventGate for batched wakes (composite channel id of the
|
|
332
|
+
* chosen event when the event carried one; push-event raw ids are not
|
|
333
|
+
* used). Routing keeps reading `channelId`, which only the direct channel
|
|
334
|
+
* paths set from normalized ids — a gate wake leaves it unset, as before.
|
|
335
|
+
*/
|
|
336
|
+
wakeChannelId?: string;
|
|
337
|
+
/** Timestamp of the event the wake provenance was taken from (ms). */
|
|
338
|
+
wakeAt?: number;
|
|
329
339
|
}
|
package/src/types/trace.ts
CHANGED
|
@@ -351,6 +351,10 @@ export type TraceEvent =
|
|
|
351
351
|
toolUseId: string;
|
|
352
352
|
isError: boolean;
|
|
353
353
|
durationMs: number;
|
|
354
|
+
/** The pair was queued behind a turn that replaced the puppet's
|
|
355
|
+
* reservation (provider-admission re-entry) instead of stored
|
|
356
|
+
* directly; it lands at that turn's end flush. */
|
|
357
|
+
deferred?: boolean;
|
|
354
358
|
})
|
|
355
359
|
|
|
356
360
|
// MCPL server connection lifecycle (spawn / handshake / reconnect health).
|