@hamak/ai-agent-backend 0.9.3 → 0.9.5
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/anthropic-provider.d.ts +17 -3
- package/dist/anthropic-provider.d.ts.map +1 -1
- package/dist/anthropic-provider.js +228 -13
- package/dist/create-agent-backend.d.ts +8 -2
- package/dist/create-agent-backend.d.ts.map +1 -1
- package/dist/file-agent-memory.d.ts +50 -1
- package/dist/file-agent-memory.d.ts.map +1 -1
- package/dist/file-agent-memory.js +160 -36
- package/package.json +5 -3
|
@@ -8,10 +8,24 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Wire format (POST https://api.anthropic.com/v1/messages):
|
|
10
10
|
* headers: x-api-key, anthropic-version: 2023-06-01, content-type: json
|
|
11
|
-
* body: { model, max_tokens, system?, messages, stream }
|
|
11
|
+
* body: { model, max_tokens, system?, tools?, messages, stream }
|
|
12
|
+
* tools: [{ name, description, input_schema }] — sent only when the loop
|
|
13
|
+
* advertises tools (`CompleteOpts.tools`).
|
|
14
|
+
* reply: content blocks `text` and `tool_use { id, name, input }`;
|
|
15
|
+
* `stop_reason: "tool_use"` when the model wants a tool run.
|
|
12
16
|
* stream: SSE — message_start (usage.input_tokens),
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* content_block_start (tool_use: id + name),
|
|
18
|
+
* content_block_delta/text_delta (delta.text) or
|
|
19
|
+
* content_block_delta/input_json_delta (delta.partial_json),
|
|
20
|
+
* content_block_stop, message_delta (usage.output_tokens),
|
|
21
|
+
* message_stop.
|
|
22
|
+
*
|
|
23
|
+
* History mapping (issue #52): assistant turns that called tools are sent as
|
|
24
|
+
* `[text?, tool_use…]` blocks and the `role: 'tool'` results that follow are
|
|
25
|
+
* grouped into ONE user message of `tool_result` blocks — the API rejects a
|
|
26
|
+
* `tool_result` that does not answer a `tool_use` in the immediately preceding
|
|
27
|
+
* assistant turn, and rejects consecutive same-role turns, so both invariants
|
|
28
|
+
* are enforced in {@link toAnthropicMessages}.
|
|
15
29
|
*
|
|
16
30
|
* Note: Opus 4.8 / 4.7 reject `temperature`/`top_p` (400), so `temperature` is
|
|
17
31
|
* sent only when explicitly configured. Default model: claude-opus-4-8.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"anthropic-provider.d.ts","sourceRoot":"","sources":["../src/anthropic-provider.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"anthropic-provider.d.ts","sourceRoot":"","sources":["../src/anthropic-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EACV,UAAU,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,cAAc,EACd,WAAW,EAIZ,MAAM,iBAAiB,CAAC;AASzB,MAAM,WAAW,uBAAuB;IACtC,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,oEAAoE;IACpE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB;AAuMD,qBAAa,iBAAkB,YAAW,cAAc;IACtD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAc;gBAExB,MAAM,GAAE,uBAA4B;IAchD,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,IAAI;IAaN,QAAQ,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA8C3E,MAAM,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,UAAU,CAAC;CA0FvF"}
|
|
@@ -8,10 +8,24 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Wire format (POST https://api.anthropic.com/v1/messages):
|
|
10
10
|
* headers: x-api-key, anthropic-version: 2023-06-01, content-type: json
|
|
11
|
-
* body: { model, max_tokens, system?, messages, stream }
|
|
11
|
+
* body: { model, max_tokens, system?, tools?, messages, stream }
|
|
12
|
+
* tools: [{ name, description, input_schema }] — sent only when the loop
|
|
13
|
+
* advertises tools (`CompleteOpts.tools`).
|
|
14
|
+
* reply: content blocks `text` and `tool_use { id, name, input }`;
|
|
15
|
+
* `stop_reason: "tool_use"` when the model wants a tool run.
|
|
12
16
|
* stream: SSE — message_start (usage.input_tokens),
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* content_block_start (tool_use: id + name),
|
|
18
|
+
* content_block_delta/text_delta (delta.text) or
|
|
19
|
+
* content_block_delta/input_json_delta (delta.partial_json),
|
|
20
|
+
* content_block_stop, message_delta (usage.output_tokens),
|
|
21
|
+
* message_stop.
|
|
22
|
+
*
|
|
23
|
+
* History mapping (issue #52): assistant turns that called tools are sent as
|
|
24
|
+
* `[text?, tool_use…]` blocks and the `role: 'tool'` results that follow are
|
|
25
|
+
* grouped into ONE user message of `tool_result` blocks — the API rejects a
|
|
26
|
+
* `tool_result` that does not answer a `tool_use` in the immediately preceding
|
|
27
|
+
* assistant turn, and rejects consecutive same-role turns, so both invariants
|
|
28
|
+
* are enforced in {@link toAnthropicMessages}.
|
|
15
29
|
*
|
|
16
30
|
* Note: Opus 4.8 / 4.7 reject `temperature`/`top_p` (400), so `temperature` is
|
|
17
31
|
* sent only when explicitly configured. Default model: claude-opus-4-8.
|
|
@@ -20,21 +34,160 @@ import { fetchWithRetry } from '@hamak/ai-agent';
|
|
|
20
34
|
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
|
21
35
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
22
36
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
23
|
-
/**
|
|
37
|
+
/** Schema sent for a tool that declares none — the API requires an object schema. */
|
|
38
|
+
const EMPTY_INPUT_SCHEMA = { type: 'object', properties: {} };
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Request mapping
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
function toAnthropicTools(tools) {
|
|
43
|
+
return tools.map((t) => ({
|
|
44
|
+
name: t.name,
|
|
45
|
+
description: t.description,
|
|
46
|
+
input_schema: t.inputSchema ?? EMPTY_INPUT_SCHEMA,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Map agent messages to Anthropic's user/assistant turns.
|
|
51
|
+
*
|
|
52
|
+
* Invariants enforced (the API 400s otherwise):
|
|
53
|
+
* - `system` messages are dropped here (sent in the top-level `system` field);
|
|
54
|
+
* - assistant `toolCalls` become `tool_use` blocks, and the `role: 'tool'`
|
|
55
|
+
* messages that follow are grouped into ONE user message of `tool_result`
|
|
56
|
+
* blocks, each paired with a `tool_use` id of that assistant turn;
|
|
57
|
+
* - a `tool_use` with no recorded result gets an `is_error` placeholder so the
|
|
58
|
+
* pairing stays complete; a result with no matching call is sent as text;
|
|
59
|
+
* - adjacent same-role turns are merged; the first turn is always `user`;
|
|
60
|
+
* - empty assistant turns (e.g. an aborted stream) are skipped.
|
|
61
|
+
*/
|
|
24
62
|
function toAnthropicMessages(messages) {
|
|
25
63
|
const out = [];
|
|
26
|
-
|
|
64
|
+
const push = (role, blocks) => {
|
|
65
|
+
if (blocks.length === 0)
|
|
66
|
+
return;
|
|
67
|
+
const last = out[out.length - 1];
|
|
68
|
+
if (last && last.role === role) {
|
|
69
|
+
last.content = [...asBlocks(last.content), ...blocks];
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
out.push({ role, content: blocks });
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/** Ids of the tool_use blocks in the latest assistant turn, awaiting results. */
|
|
76
|
+
let openCalls = [];
|
|
77
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
78
|
+
const m = messages[i];
|
|
27
79
|
if (m.role === 'system')
|
|
28
|
-
continue;
|
|
29
|
-
|
|
30
|
-
|
|
80
|
+
continue;
|
|
81
|
+
if (m.role === 'assistant') {
|
|
82
|
+
const blocks = [];
|
|
83
|
+
if (m.content)
|
|
84
|
+
blocks.push({ type: 'text', text: m.content });
|
|
85
|
+
for (const call of m.toolCalls ?? []) {
|
|
86
|
+
blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input ?? {} });
|
|
87
|
+
}
|
|
88
|
+
if (blocks.length === 0)
|
|
89
|
+
continue; // aborted / in-progress placeholder
|
|
90
|
+
// Close any still-open calls before starting a new assistant turn.
|
|
91
|
+
push('user', placeholders(openCalls));
|
|
92
|
+
openCalls = (m.toolCalls ?? []).map((c) => c.id);
|
|
93
|
+
push('assistant', blocks);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (m.role === 'tool') {
|
|
97
|
+
// Gather the whole run of consecutive tool results into one user turn.
|
|
98
|
+
const results = [];
|
|
99
|
+
const orphans = [];
|
|
100
|
+
let j = i;
|
|
101
|
+
while (j < messages.length && messages[j].role === 'tool') {
|
|
102
|
+
const t = messages[j];
|
|
103
|
+
const idx = t.toolCallId ? openCalls.indexOf(t.toolCallId) : -1;
|
|
104
|
+
if (idx !== -1 && t.toolCallId) {
|
|
105
|
+
openCalls.splice(idx, 1);
|
|
106
|
+
results.push({ type: 'tool_result', tool_use_id: t.toolCallId, content: t.content });
|
|
107
|
+
}
|
|
108
|
+
else if (t.content) {
|
|
109
|
+
orphans.push({ type: 'text', text: t.content });
|
|
110
|
+
}
|
|
111
|
+
j += 1;
|
|
112
|
+
}
|
|
113
|
+
i = j - 1;
|
|
114
|
+
// Every tool_use must be answered in this same user message.
|
|
115
|
+
results.push(...placeholders(openCalls));
|
|
116
|
+
openCalls = [];
|
|
117
|
+
push('user', [...results, ...orphans]);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// user
|
|
121
|
+
push('user', placeholders(openCalls));
|
|
122
|
+
openCalls = [];
|
|
123
|
+
if (m.content)
|
|
124
|
+
push('user', [{ type: 'text', text: m.content }]);
|
|
31
125
|
}
|
|
126
|
+
// Trailing unanswered calls (the loop always records results before the next
|
|
127
|
+
// provider call, so this only guards a truncated history).
|
|
128
|
+
push('user', placeholders(openCalls));
|
|
32
129
|
// The Messages API requires the first turn to be `user`.
|
|
33
130
|
if (out.length > 0 && out[0].role !== 'user') {
|
|
34
131
|
out.unshift({ role: 'user', content: '(context)' });
|
|
35
132
|
}
|
|
133
|
+
// Collapse text-only turns to plain strings (smaller payload, stable shape).
|
|
134
|
+
for (const turn of out)
|
|
135
|
+
turn.content = collapse(turn.content);
|
|
36
136
|
return out;
|
|
37
137
|
}
|
|
138
|
+
function placeholders(ids) {
|
|
139
|
+
return ids.map((id) => ({
|
|
140
|
+
type: 'tool_result',
|
|
141
|
+
tool_use_id: id,
|
|
142
|
+
content: 'No result was recorded for this tool call.',
|
|
143
|
+
is_error: true,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
function asBlocks(content) {
|
|
147
|
+
return typeof content === 'string' ? [{ type: 'text', text: content }] : content;
|
|
148
|
+
}
|
|
149
|
+
function collapse(content) {
|
|
150
|
+
if (typeof content === 'string')
|
|
151
|
+
return content;
|
|
152
|
+
if (content.every((b) => b.type === 'text')) {
|
|
153
|
+
return content.map((b) => b.text).join('\n\n');
|
|
154
|
+
}
|
|
155
|
+
return content;
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Response mapping
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* Parse a streamed `tool_use` input. Unparsable JSON is an error, never a
|
|
162
|
+
* tool call: executing a tool with garbage input is worse than failing the
|
|
163
|
+
* turn, and a truncated input is handled by the caller via `stop_reason`.
|
|
164
|
+
*/
|
|
165
|
+
function parseToolInput(raw, call) {
|
|
166
|
+
const trimmed = raw.trim();
|
|
167
|
+
if (!trimmed)
|
|
168
|
+
return {};
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(trimmed);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new Error(`[anthropic] tool_use input for "${call.name}" (${call.id}) is not valid JSON`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Map the wire `stop_reason` to the provider-neutral {@link StopReason}. */
|
|
177
|
+
function toStopReason(raw) {
|
|
178
|
+
switch (raw) {
|
|
179
|
+
case 'end_turn':
|
|
180
|
+
case 'tool_use':
|
|
181
|
+
case 'max_tokens':
|
|
182
|
+
case 'refusal':
|
|
183
|
+
case 'stop_sequence':
|
|
184
|
+
return raw;
|
|
185
|
+
default:
|
|
186
|
+
// `pause_turn` (server-tool long turns: resend to continue) and anything
|
|
187
|
+
// newer are left undefined rather than mistaken for a finished turn.
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
38
191
|
let seq = 0;
|
|
39
192
|
function genId() {
|
|
40
193
|
seq += 1;
|
|
@@ -70,6 +223,7 @@ export class AnthropicProvider {
|
|
|
70
223
|
...(opts.systemPrompt ? { system: opts.systemPrompt } : {}),
|
|
71
224
|
// Only send temperature when explicitly set — Opus 4.8/4.7 reject it.
|
|
72
225
|
...(opts.temperature != null ? { temperature: opts.temperature } : {}),
|
|
226
|
+
...(opts.tools && opts.tools.length > 0 ? { tools: toAnthropicTools(opts.tools) } : {}),
|
|
73
227
|
messages: toAnthropicMessages(messages),
|
|
74
228
|
stream,
|
|
75
229
|
});
|
|
@@ -85,17 +239,32 @@ export class AnthropicProvider {
|
|
|
85
239
|
throw new Error(`[anthropic] ${res.status} ${res.statusText}: ${await safeText(res)}`);
|
|
86
240
|
}
|
|
87
241
|
const json = (await res.json());
|
|
88
|
-
const
|
|
242
|
+
const blocks = json.content ?? [];
|
|
243
|
+
const stopReason = toStopReason(json.stop_reason);
|
|
244
|
+
const content = blocks
|
|
89
245
|
.filter((b) => b.type === 'text')
|
|
90
246
|
.map((b) => b.text ?? '')
|
|
91
247
|
.join('');
|
|
248
|
+
// A `max_tokens` stop can leave a tool_use block with a cut-off input; the
|
|
249
|
+
// JSON body gives no per-block completeness signal, so no tool call from
|
|
250
|
+
// such a response is executed — the stop reason tells the loop why.
|
|
251
|
+
const toolCalls = stopReason === 'max_tokens'
|
|
252
|
+
? []
|
|
253
|
+
: blocks
|
|
254
|
+
.filter((b) => b.type === 'tool_use')
|
|
255
|
+
.map((b, n) => ({ id: b.id ?? `toolu-${genId()}-${n}`, name: b.name ?? '', input: b.input ?? {} }));
|
|
92
256
|
return {
|
|
93
257
|
message: {
|
|
94
258
|
id: genId(),
|
|
95
259
|
role: 'assistant',
|
|
96
260
|
content,
|
|
261
|
+
...(toolCalls.length > 0
|
|
262
|
+
? { toolCalls: toolCalls.map((c) => ({ id: c.id, name: c.name, input: c.input })) }
|
|
263
|
+
: {}),
|
|
97
264
|
createdAt: new Date().toISOString(),
|
|
98
265
|
},
|
|
266
|
+
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
|
267
|
+
...(stopReason ? { stopReason } : {}),
|
|
99
268
|
usage: {
|
|
100
269
|
inputTokens: json.usage?.input_tokens ?? 0,
|
|
101
270
|
outputTokens: json.usage?.output_tokens ?? 0,
|
|
@@ -118,6 +287,10 @@ export class AnthropicProvider {
|
|
|
118
287
|
let buffer = '';
|
|
119
288
|
let inputTokens = 0;
|
|
120
289
|
let outputTokens = 0;
|
|
290
|
+
/** In-flight `tool_use` blocks keyed by content-block index. */
|
|
291
|
+
const pending = new Map();
|
|
292
|
+
const toolCalls = [];
|
|
293
|
+
let stopReason;
|
|
121
294
|
try {
|
|
122
295
|
for (;;) {
|
|
123
296
|
const { value, done } = await reader.read();
|
|
@@ -134,15 +307,45 @@ export class AnthropicProvider {
|
|
|
134
307
|
if (!payload || payload === '[DONE]')
|
|
135
308
|
continue;
|
|
136
309
|
const evt = JSON.parse(payload);
|
|
310
|
+
const index = evt.index ?? 0;
|
|
137
311
|
if (evt.type === 'message_start') {
|
|
138
312
|
inputTokens = evt.message?.usage?.input_tokens ?? inputTokens;
|
|
139
313
|
}
|
|
140
|
-
else if (evt.type === '
|
|
141
|
-
if (evt.
|
|
142
|
-
|
|
314
|
+
else if (evt.type === 'content_block_start') {
|
|
315
|
+
if (evt.content_block?.type === 'tool_use') {
|
|
316
|
+
pending.set(index, {
|
|
317
|
+
id: evt.content_block.id ?? `toolu-${genId()}-${index}`,
|
|
318
|
+
name: evt.content_block.name ?? '',
|
|
319
|
+
json: '',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
else if (evt.content_block?.type === 'text' && evt.content_block.text) {
|
|
323
|
+
yield { type: 'text-delta', delta: evt.content_block.text };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else if (evt.type === 'content_block_delta') {
|
|
327
|
+
if (evt.delta?.type === 'text_delta') {
|
|
328
|
+
if (evt.delta.text)
|
|
329
|
+
yield { type: 'text-delta', delta: evt.delta.text };
|
|
330
|
+
}
|
|
331
|
+
else if (evt.delta?.type === 'input_json_delta') {
|
|
332
|
+
const p = pending.get(index);
|
|
333
|
+
if (p)
|
|
334
|
+
p.json += evt.delta.partial_json ?? '';
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else if (evt.type === 'content_block_stop') {
|
|
338
|
+
const p = pending.get(index);
|
|
339
|
+
if (p) {
|
|
340
|
+
pending.delete(index);
|
|
341
|
+
const call = { id: p.id, name: p.name, input: parseToolInput(p.json, p) };
|
|
342
|
+
toolCalls.push(call);
|
|
343
|
+
yield { type: 'tool-call', call };
|
|
344
|
+
}
|
|
143
345
|
}
|
|
144
346
|
else if (evt.type === 'message_delta') {
|
|
145
347
|
outputTokens = evt.usage?.output_tokens ?? outputTokens;
|
|
348
|
+
stopReason = toStopReason(evt.delta?.stop_reason) ?? stopReason;
|
|
146
349
|
}
|
|
147
350
|
}
|
|
148
351
|
}
|
|
@@ -150,9 +353,21 @@ export class AnthropicProvider {
|
|
|
150
353
|
finally {
|
|
151
354
|
reader.releaseLock();
|
|
152
355
|
}
|
|
356
|
+
// A tool_use block that never got its `content_block_stop` has an
|
|
357
|
+
// incomplete input. Cut off by the token limit it is dropped (the loop sees
|
|
358
|
+
// `stopReason: 'max_tokens'`); any other cause is a broken stream.
|
|
359
|
+
if (pending.size > 0 && stopReason !== 'max_tokens') {
|
|
360
|
+
const names = [...pending.values()].map((p) => p.name).join(', ');
|
|
361
|
+
throw new Error(`[anthropic] stream ended before the tool_use input for ${names} was complete`);
|
|
362
|
+
}
|
|
153
363
|
const usage = { inputTokens, outputTokens };
|
|
154
364
|
yield { type: 'usage', usage };
|
|
155
|
-
yield {
|
|
365
|
+
yield {
|
|
366
|
+
type: 'done',
|
|
367
|
+
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
|
368
|
+
...(stopReason ? { stopReason } : {}),
|
|
369
|
+
usage,
|
|
370
|
+
};
|
|
156
371
|
}
|
|
157
372
|
}
|
|
158
373
|
async function safeText(res) {
|
|
@@ -22,8 +22,14 @@ export interface AgentBackendConfig {
|
|
|
22
22
|
tools?: IAgentTool[];
|
|
23
23
|
/**
|
|
24
24
|
* Tool approval gate (§6) applied to backend *and* frontend (split-loop)
|
|
25
|
-
* tools. Omitted = allow everything (back-compat).
|
|
26
|
-
*
|
|
25
|
+
* tools. Omitted = allow everything (back-compat).
|
|
26
|
+
*
|
|
27
|
+
* An authorizer that *reviews* (rather than auto-allowing) suspends the run
|
|
28
|
+
* until an approve/deny arrives, and only `createAgentChannelRoutes` carries
|
|
29
|
+
* those: its `POST /commands` relays the full `AgentCommand` set. Mounting a
|
|
30
|
+
* reviewing authorizer behind `createAgentRoutes` alone — whose `/chat` SSE
|
|
31
|
+
* relays only `token`/`usage`/`turn-end` — leaves the caller with a request
|
|
32
|
+
* that never completes, because nothing can deliver the decision.
|
|
27
33
|
*/
|
|
28
34
|
authorizer?: IToolAuthorizer;
|
|
29
35
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-agent-backend.d.ts","sourceRoot":"","sources":["../src/create-agent-backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,SAAS,CAAC,EAAE,uBAAuB,CAAC;IACpC,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;IAC7B,0BAA0B;IAC1B,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB
|
|
1
|
+
{"version":3,"file":"create-agent-backend.d.ts","sourceRoot":"","sources":["../src/create-agent-backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,SAAS,CAAC,EAAE,uBAAuB,CAAC;IACpC,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;IAC7B,0BAA0B;IAC1B,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,eAAe,CAAC;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,QAAQ,CAAC;IAChB,MAAM,EAAE,eAAe,CAAC;IACxB,SAAS,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACpC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC5B,0EAA0E;IAC1E,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAgC3E"}
|
|
@@ -5,21 +5,70 @@
|
|
|
5
5
|
*
|
|
6
6
|
* This is the backend analogue of the frontend's `StoreFsMemory` — same
|
|
7
7
|
* `IAgentMemory` contract, different substrate (node `fs` vs the virtual store).
|
|
8
|
+
*
|
|
9
|
+
* Durability model (issue #53):
|
|
10
|
+
* - **Atomic writes.** Every write goes to a unique `<file>.<pid>.<n>.tmp` in
|
|
11
|
+
* the *same* directory, is flushed with `fsync`, and is then `rename`d over
|
|
12
|
+
* the target. `rename` within a directory is atomic, so a reader (or a crash)
|
|
13
|
+
* never observes a half-written conversation — it sees the previous complete
|
|
14
|
+
* file or the new complete file. The temp file is removed if anything fails.
|
|
15
|
+
* - **Per-conversation serialisation.** Every read-modify-write method runs
|
|
16
|
+
* through a promise chain keyed by `(agentId, sessionId)`, so concurrent
|
|
17
|
+
* `append()`s apply in call order instead of racing and dropping messages.
|
|
18
|
+
* The chain entry is dropped once it drains, so the map cannot grow forever.
|
|
19
|
+
*
|
|
20
|
+
* The on-disk format is unchanged from 0.9.3: a single pretty-printed JSON
|
|
21
|
+
* `Conversation` document per file. That keeps every existing reader (and the
|
|
22
|
+
* fixtures) working; the cost is O(n) bytes rewritten per append, which is
|
|
23
|
+
* accepted for now — correctness first, an append-only log can come later
|
|
24
|
+
* behind the same class.
|
|
8
25
|
*/
|
|
9
|
-
import type { AgentMessage, Conversation, ConversationSummary, ConversationUsage } from '@hamak/ai-agent';
|
|
26
|
+
import type { AgentMessage, CompactionCheckpoint, Conversation, ConversationSummary, ConversationUsage, InterAgentMessage, PendingApproval } from '@hamak/ai-agent';
|
|
10
27
|
import type { IAgentMemory } from '@hamak/ai-agent/spi';
|
|
11
28
|
export declare class FileAgentMemory implements IAgentMemory {
|
|
12
29
|
private readonly root;
|
|
30
|
+
/**
|
|
31
|
+
* Tail of the in-flight operation chain per `(agentId, sessionId)`. Never
|
|
32
|
+
* rejects (failures are swallowed on the chain copy so one failed write does
|
|
33
|
+
* not poison the queue); the caller still gets the real rejection.
|
|
34
|
+
*/
|
|
35
|
+
private readonly chains;
|
|
13
36
|
constructor(root: string);
|
|
14
37
|
private dir;
|
|
15
38
|
private file;
|
|
39
|
+
private key;
|
|
40
|
+
/**
|
|
41
|
+
* Run `task` after every previously queued task for this conversation. Errors
|
|
42
|
+
* propagate to the caller but do not break the chain for later tasks.
|
|
43
|
+
*/
|
|
44
|
+
private serialize;
|
|
16
45
|
get(agentId: string, sessionId: string): Promise<Conversation | null>;
|
|
17
46
|
load(agentId: string, sessionId: string): Promise<AgentMessage[]>;
|
|
47
|
+
/**
|
|
48
|
+
* Write `data` to `file` atomically: temp file in the same directory, fsync,
|
|
49
|
+
* rename. The temp file is cleaned up on every failure path.
|
|
50
|
+
*/
|
|
51
|
+
private writeFileAtomic;
|
|
18
52
|
private write;
|
|
53
|
+
/**
|
|
54
|
+
* Read-modify-write helper: loads the conversation, hands it to `mutate`, and
|
|
55
|
+
* writes back whatever `mutate` returns (returning `null` writes nothing).
|
|
56
|
+
* Always runs inside the per-conversation chain.
|
|
57
|
+
*/
|
|
58
|
+
private mutate;
|
|
19
59
|
append(agentId: string, sessionId: string, message: AgentMessage): Promise<void>;
|
|
20
60
|
update(agentId: string, sessionId: string, messageId: string, changes: Partial<Pick<AgentMessage, 'content' | 'toolCalls'>>): Promise<void>;
|
|
21
61
|
patch(agentId: string, sessionId: string, changes: Partial<Pick<Conversation, 'title' | 'pinned' | 'systemPrompt'>>): Promise<void>;
|
|
62
|
+
/** Replace the pending-approval list (ticket 009 §4). */
|
|
63
|
+
setPendingApprovals(agentId: string, sessionId: string, pending: PendingApproval[]): Promise<void>;
|
|
64
|
+
/** Record an inter-agent communication edge (ticket 009 §5.3). */
|
|
65
|
+
appendInterAgentMessage(agentId: string, sessionId: string, message: InterAgentMessage): Promise<void>;
|
|
66
|
+
/** Replace a conversation wholesale with a domain record (ticket 009 §8). */
|
|
67
|
+
hydrate(agentId: string, sessionId: string, conversation: Conversation): Promise<void>;
|
|
68
|
+
/** Replace the cumulative token usage (absolute, so replays stay idempotent). */
|
|
22
69
|
setUsage(agentId: string, sessionId: string, usage: ConversationUsage): Promise<void>;
|
|
70
|
+
/** Replace the context-compaction checkpoint (absolute, not a delta). */
|
|
71
|
+
setCompaction(agentId: string, sessionId: string, compaction: CompactionCheckpoint): Promise<void>;
|
|
23
72
|
reset(agentId: string, sessionId: string): Promise<void>;
|
|
24
73
|
list(agentId: string): Promise<ConversationSummary[]>;
|
|
25
74
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-agent-memory.d.ts","sourceRoot":"","sources":["../src/file-agent-memory.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"file-agent-memory.d.ts","sourceRoot":"","sources":["../src/file-agent-memory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EACpB,YAAY,EACZ,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EAChB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAuBxD,qBAAa,eAAgB,YAAW,YAAY;IAQtC,OAAO,CAAC,QAAQ,CAAC,IAAI;IAPjC;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;gBAE9B,IAAI,EAAE,MAAM;IAEzC,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,GAAG;IAIX;;;OAGG;IACH,OAAO,CAAC,SAAS;IAkBX,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAUrE,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAIvE;;;OAGG;YACW,eAAe;YA8Bf,KAAK;IAKnB;;;;OAIG;IACH,OAAO,CAAC,MAAM;IAaR,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBhF,MAAM,CACV,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,WAAW,CAAC,CAAC,GAC5D,OAAO,CAAC,IAAI,CAAC;IAWV,KAAK,CACT,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,GAAG,QAAQ,GAAG,cAAc,CAAC,CAAC,GACxE,OAAO,CAAC,IAAI,CAAC;IAMhB,yDAAyD;IACnD,mBAAmB,CACvB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,eAAe,EAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IAShB,kEAAkE;IAC5D,uBAAuB,CAC3B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC;IAShB,6EAA6E;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAI5F,iFAAiF;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAM3F,yEAAyE;IACnE,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,GAC/B,OAAO,CAAC,IAAI,CAAC;IAMV,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMxD,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;CAuB5D"}
|
|
@@ -5,6 +5,23 @@
|
|
|
5
5
|
*
|
|
6
6
|
* This is the backend analogue of the frontend's `StoreFsMemory` — same
|
|
7
7
|
* `IAgentMemory` contract, different substrate (node `fs` vs the virtual store).
|
|
8
|
+
*
|
|
9
|
+
* Durability model (issue #53):
|
|
10
|
+
* - **Atomic writes.** Every write goes to a unique `<file>.<pid>.<n>.tmp` in
|
|
11
|
+
* the *same* directory, is flushed with `fsync`, and is then `rename`d over
|
|
12
|
+
* the target. `rename` within a directory is atomic, so a reader (or a crash)
|
|
13
|
+
* never observes a half-written conversation — it sees the previous complete
|
|
14
|
+
* file or the new complete file. The temp file is removed if anything fails.
|
|
15
|
+
* - **Per-conversation serialisation.** Every read-modify-write method runs
|
|
16
|
+
* through a promise chain keyed by `(agentId, sessionId)`, so concurrent
|
|
17
|
+
* `append()`s apply in call order instead of racing and dropping messages.
|
|
18
|
+
* The chain entry is dropped once it drains, so the map cannot grow forever.
|
|
19
|
+
*
|
|
20
|
+
* The on-disk format is unchanged from 0.9.3: a single pretty-printed JSON
|
|
21
|
+
* `Conversation` document per file. That keeps every existing reader (and the
|
|
22
|
+
* fixtures) working; the cost is O(n) bytes rewritten per append, which is
|
|
23
|
+
* accepted for now — correctness first, an append-only log can come later
|
|
24
|
+
* behind the same class.
|
|
8
25
|
*/
|
|
9
26
|
import { promises as fs } from 'node:fs';
|
|
10
27
|
import path from 'node:path';
|
|
@@ -25,9 +42,17 @@ function safeSegment(seg) {
|
|
|
25
42
|
}
|
|
26
43
|
return seg;
|
|
27
44
|
}
|
|
45
|
+
/** Process-wide temp-file counter, so two instances never collide on a name. */
|
|
46
|
+
let tmpCounter = 0;
|
|
28
47
|
export class FileAgentMemory {
|
|
29
48
|
constructor(root) {
|
|
30
49
|
this.root = root;
|
|
50
|
+
/**
|
|
51
|
+
* Tail of the in-flight operation chain per `(agentId, sessionId)`. Never
|
|
52
|
+
* rejects (failures are swallowed on the chain copy so one failed write does
|
|
53
|
+
* not poison the queue); the caller still gets the real rejection.
|
|
54
|
+
*/
|
|
55
|
+
this.chains = new Map();
|
|
31
56
|
}
|
|
32
57
|
dir(agentId) {
|
|
33
58
|
return path.join(this.root, safeSegment(agentId));
|
|
@@ -35,6 +60,28 @@ export class FileAgentMemory {
|
|
|
35
60
|
file(agentId, sessionId) {
|
|
36
61
|
return path.join(this.dir(agentId), `${safeSegment(sessionId)}.json`);
|
|
37
62
|
}
|
|
63
|
+
key(agentId, sessionId) {
|
|
64
|
+
return `${agentId}\u0000${sessionId}`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run `task` after every previously queued task for this conversation. Errors
|
|
68
|
+
* propagate to the caller but do not break the chain for later tasks.
|
|
69
|
+
*/
|
|
70
|
+
serialize(agentId, sessionId, task) {
|
|
71
|
+
// Validate eagerly so traversal attempts reject before touching the queue.
|
|
72
|
+
this.file(agentId, sessionId);
|
|
73
|
+
const key = this.key(agentId, sessionId);
|
|
74
|
+
const prev = this.chains.get(key) ?? Promise.resolve();
|
|
75
|
+
const run = prev.then(task);
|
|
76
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
77
|
+
this.chains.set(key, settled);
|
|
78
|
+
void settled.then(() => {
|
|
79
|
+
// Only the last task in the chain clears the entry.
|
|
80
|
+
if (this.chains.get(key) === settled)
|
|
81
|
+
this.chains.delete(key);
|
|
82
|
+
});
|
|
83
|
+
return run;
|
|
84
|
+
}
|
|
38
85
|
async get(agentId, sessionId) {
|
|
39
86
|
try {
|
|
40
87
|
const raw = await fs.readFile(this.file(agentId, sessionId), 'utf8');
|
|
@@ -49,56 +96,133 @@ export class FileAgentMemory {
|
|
|
49
96
|
async load(agentId, sessionId) {
|
|
50
97
|
return (await this.get(agentId, sessionId))?.messages ?? [];
|
|
51
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Write `data` to `file` atomically: temp file in the same directory, fsync,
|
|
101
|
+
* rename. The temp file is cleaned up on every failure path.
|
|
102
|
+
*/
|
|
103
|
+
async writeFileAtomic(file, data) {
|
|
104
|
+
tmpCounter += 1;
|
|
105
|
+
const tmp = `${file}.${process.pid}.${tmpCounter}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
const handle = await fs.open(tmp, 'w');
|
|
108
|
+
try {
|
|
109
|
+
await handle.writeFile(data, 'utf8');
|
|
110
|
+
await handle.sync();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await handle.close();
|
|
114
|
+
}
|
|
115
|
+
await fs.rename(tmp, file);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
// Best-effort: flush the directory entry so the rename itself is durable.
|
|
122
|
+
// Not supported everywhere (Windows), hence the swallow.
|
|
123
|
+
try {
|
|
124
|
+
const dirHandle = await fs.open(path.dirname(file), 'r');
|
|
125
|
+
try {
|
|
126
|
+
await dirHandle.sync();
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await dirHandle.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* ignore */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
52
136
|
async write(agentId, sessionId, conv) {
|
|
53
137
|
await fs.mkdir(this.dir(agentId), { recursive: true });
|
|
54
|
-
await
|
|
138
|
+
await this.writeFileAtomic(this.file(agentId, sessionId), JSON.stringify(conv, null, 2));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Read-modify-write helper: loads the conversation, hands it to `mutate`, and
|
|
142
|
+
* writes back whatever `mutate` returns (returning `null` writes nothing).
|
|
143
|
+
* Always runs inside the per-conversation chain.
|
|
144
|
+
*/
|
|
145
|
+
mutate(agentId, sessionId, mutate) {
|
|
146
|
+
return this.serialize(agentId, sessionId, async () => {
|
|
147
|
+
const existing = await this.get(agentId, sessionId);
|
|
148
|
+
const next = mutate(existing);
|
|
149
|
+
if (!next)
|
|
150
|
+
return;
|
|
151
|
+
await this.write(agentId, sessionId, next);
|
|
152
|
+
});
|
|
55
153
|
}
|
|
56
154
|
async append(agentId, sessionId, message) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
155
|
+
return this.mutate(agentId, sessionId, (existing) => {
|
|
156
|
+
const now = nowIso();
|
|
157
|
+
if (existing) {
|
|
158
|
+
existing.messages.push(message);
|
|
159
|
+
existing.updatedAt = now;
|
|
160
|
+
if (existing.title === '' && message.role === 'user') {
|
|
161
|
+
existing.title = deriveTitle(message.content);
|
|
162
|
+
}
|
|
163
|
+
return existing;
|
|
64
164
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
createdAt: now,
|
|
74
|
-
updatedAt: now,
|
|
165
|
+
return {
|
|
166
|
+
schemaVersion: CONVERSATION_SCHEMA_VERSION,
|
|
167
|
+
id: sessionId,
|
|
168
|
+
title: message.role === 'user' ? deriveTitle(message.content) : '',
|
|
169
|
+
messages: [message],
|
|
170
|
+
createdAt: now,
|
|
171
|
+
updatedAt: now,
|
|
172
|
+
};
|
|
75
173
|
});
|
|
76
174
|
}
|
|
77
175
|
async update(agentId, sessionId, messageId, changes) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
176
|
+
return this.mutate(agentId, sessionId, (existing) => {
|
|
177
|
+
if (!existing)
|
|
178
|
+
return null;
|
|
179
|
+
const idx = existing.messages.findIndex((m) => m.id === messageId);
|
|
180
|
+
if (idx === -1)
|
|
181
|
+
return null;
|
|
182
|
+
existing.messages[idx] = { ...existing.messages[idx], ...changes };
|
|
183
|
+
existing.updatedAt = nowIso();
|
|
184
|
+
return existing;
|
|
185
|
+
});
|
|
87
186
|
}
|
|
88
187
|
async patch(agentId, sessionId, changes) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
188
|
+
return this.mutate(agentId, sessionId, (existing) => existing ? { ...existing, ...changes, updatedAt: nowIso() } : null);
|
|
189
|
+
}
|
|
190
|
+
/** Replace the pending-approval list (ticket 009 §4). */
|
|
191
|
+
async setPendingApprovals(agentId, sessionId, pending) {
|
|
192
|
+
return this.mutate(agentId, sessionId, (existing) => {
|
|
193
|
+
if (!existing)
|
|
194
|
+
return null;
|
|
195
|
+
existing.pendingApprovals = pending;
|
|
196
|
+
existing.updatedAt = nowIso();
|
|
197
|
+
return existing;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** Record an inter-agent communication edge (ticket 009 §5.3). */
|
|
201
|
+
async appendInterAgentMessage(agentId, sessionId, message) {
|
|
202
|
+
return this.mutate(agentId, sessionId, (existing) => {
|
|
203
|
+
if (!existing)
|
|
204
|
+
return null;
|
|
205
|
+
existing.interAgentMessages = [...(existing.interAgentMessages ?? []), message];
|
|
206
|
+
existing.updatedAt = nowIso();
|
|
207
|
+
return existing;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** Replace a conversation wholesale with a domain record (ticket 009 §8). */
|
|
211
|
+
async hydrate(agentId, sessionId, conversation) {
|
|
212
|
+
return this.mutate(agentId, sessionId, () => ({ ...conversation }));
|
|
93
213
|
}
|
|
214
|
+
/** Replace the cumulative token usage (absolute, so replays stay idempotent). */
|
|
94
215
|
async setUsage(agentId, sessionId, usage) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
216
|
+
return this.mutate(agentId, sessionId, (existing) => existing ? { ...existing, usage, updatedAt: nowIso() } : null);
|
|
217
|
+
}
|
|
218
|
+
/** Replace the context-compaction checkpoint (absolute, not a delta). */
|
|
219
|
+
async setCompaction(agentId, sessionId, compaction) {
|
|
220
|
+
return this.mutate(agentId, sessionId, (existing) => existing ? { ...existing, compaction, updatedAt: nowIso() } : null);
|
|
99
221
|
}
|
|
100
222
|
async reset(agentId, sessionId) {
|
|
101
|
-
|
|
223
|
+
return this.serialize(agentId, sessionId, async () => {
|
|
224
|
+
await fs.rm(this.file(agentId, sessionId), { force: true });
|
|
225
|
+
});
|
|
102
226
|
}
|
|
103
227
|
async list(agentId) {
|
|
104
228
|
let names;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hamak/ai-agent-backend",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Backend for @hamak/ai-agent: hosted-model providers (Anthropic), durable memory, and an Express agent-chat route",
|
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsc && node ../../../../scripts/fix-esm-extensions.mjs ./dist",
|
|
23
23
|
"clean": "rm -rf dist",
|
|
24
|
-
"
|
|
25
|
-
"test
|
|
24
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
25
|
+
"test": "npm run typecheck && vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"prepublishOnly": "node ../../../../scripts/check-publish-integrity.mjs"
|
|
26
28
|
},
|
|
27
29
|
"exports": {
|
|
28
30
|
".": {
|