@code-yeongyu/senpi-agent-core 2026.9.30 → 2026.10.1-2
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/README.md +49 -20
- package/dist/agent-loop.d.ts +27 -3
- package/dist/agent-loop.js +192 -56
- package/dist/agent.d.ts +21 -7
- package/dist/agent.js +38 -18
- package/dist/harness/execution/tools.d.ts +1 -1
- package/dist/harness/execution/tools.js +0 -2
- package/dist/harness/messages.js +1 -0
- package/dist/harness/pico3/bash.d.ts +10 -0
- package/dist/harness/pico3/bash.js +25 -0
- package/dist/harness/pico3/bounded.d.ts +21 -0
- package/dist/harness/pico3/bounded.js +95 -0
- package/dist/harness/pico3/chord.d.ts +41 -0
- package/dist/harness/pico3/chord.js +147 -0
- package/dist/harness/pico3/context.d.ts +16 -0
- package/dist/harness/pico3/context.js +88 -0
- package/dist/harness/pico3/harness.d.ts +198 -0
- package/dist/harness/pico3/harness.js +645 -0
- package/dist/harness/pico3/hooks.d.ts +10 -0
- package/dist/harness/pico3/hooks.js +33 -0
- package/dist/harness/pico3/index.d.ts +19 -0
- package/dist/harness/pico3/index.js +15 -0
- package/dist/harness/pico3/jsonl.d.ts +58 -0
- package/dist/harness/pico3/jsonl.js +322 -0
- package/dist/harness/pico3/kinds/collapse.d.ts +49 -0
- package/dist/harness/pico3/kinds/collapse.js +191 -0
- package/dist/harness/pico3/kinds/entries.d.ts +78 -0
- package/dist/harness/pico3/kinds/entries.js +13 -0
- package/dist/harness/pico3/kinds/frames.d.ts +9 -0
- package/dist/harness/pico3/kinds/frames.js +75 -0
- package/dist/harness/pico3/kinds/generation.d.ts +94 -0
- package/dist/harness/pico3/kinds/generation.js +504 -0
- package/dist/harness/pico3/kinds/job.d.ts +44 -0
- package/dist/harness/pico3/kinds/job.js +143 -0
- package/dist/harness/pico3/kinds/plugin.d.ts +15 -0
- package/dist/harness/pico3/kinds/plugin.js +35 -0
- package/dist/harness/pico3/kinds/post-tools.d.ts +22 -0
- package/dist/harness/pico3/kinds/post-tools.js +145 -0
- package/dist/harness/pico3/kinds/task-api.d.ts +3 -0
- package/dist/harness/pico3/kinds/task-api.js +45 -0
- package/dist/harness/pico3/kinds/tool.d.ts +43 -0
- package/dist/harness/pico3/kinds/tool.js +373 -0
- package/dist/harness/pico3/legacy-tracker.d.ts +11 -0
- package/dist/harness/pico3/legacy-tracker.js +39 -0
- package/dist/harness/pico3/membrane.d.ts +23 -0
- package/dist/harness/pico3/membrane.js +140 -0
- package/dist/harness/pico3/memory.d.ts +53 -0
- package/dist/harness/pico3/memory.js +265 -0
- package/dist/harness/pico3/scheduler.d.ts +49 -0
- package/dist/harness/pico3/scheduler.js +437 -0
- package/dist/harness/pico3/session.d.ts +264 -0
- package/dist/harness/pico3/session.js +1328 -0
- package/dist/harness/pico3/system.d.ts +126 -0
- package/dist/harness/pico3/system.js +244 -0
- package/dist/harness/pico3/types.d.ts +973 -0
- package/dist/harness/pico3/types.js +94 -0
- package/dist/harness/pico3/view.d.ts +34 -0
- package/dist/harness/pico3/view.js +404 -0
- package/dist/harness/runtime/drive/tool-placement.js +2 -27
- package/dist/harness/telemetry.d.ts +36 -36
- package/dist/harness/tools/image.js +1 -1
- package/dist/proxy.d.ts +2 -2
- package/dist/types.d.ts +111 -34
- package/package.json +9 -5
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { AssistantMessageFrameEncoder, isRetryableAssistantError } from "@earendil-works/pi-ai";
|
|
2
|
+
import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
|
|
3
|
+
import { planManagedEntry, prepareDraft, sameSnapshot, takeSnapshot } from "../system.js";
|
|
4
|
+
import { toStored, } from "../types.js";
|
|
5
|
+
import { chooseThrough } from "./collapse.js";
|
|
6
|
+
import { applyFrame } from "./frames.js";
|
|
7
|
+
/** Configuration this kind reads, with defaults. `model` has none: absent → failed/no_model. */
|
|
8
|
+
export const generationConfig = {
|
|
9
|
+
rewindable: {
|
|
10
|
+
model: undefined,
|
|
11
|
+
thinkingLevel: "off",
|
|
12
|
+
selectedTools: [],
|
|
13
|
+
profile: "default",
|
|
14
|
+
},
|
|
15
|
+
sticky: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60_000 } },
|
|
16
|
+
};
|
|
17
|
+
const fail = (reason, detail, assistant) => ({
|
|
18
|
+
status: "failed",
|
|
19
|
+
failure: { reason, detail, ...(assistant === undefined ? {} : { assistant }) },
|
|
20
|
+
});
|
|
21
|
+
/** Every terminal generation failure settles its group and admits queued triggers at the final boundary. */
|
|
22
|
+
const failStep = (task, reason, detail) => ({
|
|
23
|
+
done: async (tx, current) => {
|
|
24
|
+
const head = await tx.newestEntry(current.conversationId, { withHead: true });
|
|
25
|
+
tx.emit({ type: "generation.failed", taskId: current.id, reason, detail });
|
|
26
|
+
await settleFailedTurn(tx, current, task.input.inputs, detail, head?.id);
|
|
27
|
+
return fail(reason, detail);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
/** pi-ai drops system messages and aborted/error assistant messages before sending; estimate what is sent. */
|
|
31
|
+
const estimate = (messages) => estimateContextTokens(messages.filter((m) => m.role !== "system" &&
|
|
32
|
+
!(m.role === "assistant" && (m.stopReason === "aborted" || m.stopReason === "error")))).tokens;
|
|
33
|
+
const emptyUsage = () => ({
|
|
34
|
+
input: 0,
|
|
35
|
+
output: 0,
|
|
36
|
+
cacheRead: 0,
|
|
37
|
+
cacheWrite: 0,
|
|
38
|
+
totalTokens: 0,
|
|
39
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
40
|
+
});
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// The kind
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
export const generation = {
|
|
45
|
+
name: "pi.generation",
|
|
46
|
+
turn: true,
|
|
47
|
+
config: generationConfig,
|
|
48
|
+
inflight: ["requesting"],
|
|
49
|
+
// Step 1 (§12.5): snapshot S on the line; systemInstructions off the line; then in one
|
|
50
|
+
// commit re-take S′, retry if it moved, else append the managed entry and checkpoint
|
|
51
|
+
// `prepared` with the cutoff. Runs once per generation; never on recovery.
|
|
52
|
+
async initial(task, rt, ctx) {
|
|
53
|
+
const { snapshot, canonical, seed } = await rt.commit((tx, current) => takeSnapshot(tx, current.conversationId, rt.registries.sections, rt.registries.tools), ctx);
|
|
54
|
+
if (snapshot.settings.model === undefined)
|
|
55
|
+
return failStep(task, "no_model", "no model configured");
|
|
56
|
+
const retry = (await rt.sticky(task.conversationId, ctx)).retry;
|
|
57
|
+
const warnings = [];
|
|
58
|
+
const { desired, tools } = await prepareDraft(rt, rt.registries.sections, canonical, seed, snapshot.settings, (message) => warnings.push(message), ctx);
|
|
59
|
+
const model = snapshot.settings.model;
|
|
60
|
+
const thinkingLevel = snapshot.settings.thinkingLevel;
|
|
61
|
+
return {
|
|
62
|
+
next: async (tx, current) => {
|
|
63
|
+
const c = current.conversationId;
|
|
64
|
+
const again = await takeSnapshot(tx, c, rt.registries.sections, rt.registries.tools);
|
|
65
|
+
if (!sameSnapshot(again.snapshot, snapshot))
|
|
66
|
+
return "retry";
|
|
67
|
+
const plan = await planManagedEntry(tx, c, snapshot, canonical, desired, tools, rt.now());
|
|
68
|
+
for (const message of warnings)
|
|
69
|
+
tx.emit({ type: "warning", source: "generation", message });
|
|
70
|
+
const system = plan === undefined ? null : tx.appendEntry(c, { kind: "pi.system", ...plan });
|
|
71
|
+
const cutoff = system ?? (await tx.newestEntry(c)).id;
|
|
72
|
+
tx.sticky(c).turn = { tools: [] }; // a new generation starts a fresh turn view
|
|
73
|
+
return {
|
|
74
|
+
phase: "prepared",
|
|
75
|
+
cutoff,
|
|
76
|
+
system,
|
|
77
|
+
model,
|
|
78
|
+
thinkingLevel,
|
|
79
|
+
tools: tools.map((t) => t.name),
|
|
80
|
+
retry,
|
|
81
|
+
attempt: 0,
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
phases: {
|
|
87
|
+
// Step 2: derive the request from the cutoff (hooks rerun), overflow check, write the
|
|
88
|
+
// in-flight checkpoint, call the provider. Reached fresh from `initial`/`retrying`,
|
|
89
|
+
// and again on recovery of `prepared` (step 1 never reruns).
|
|
90
|
+
async prepared(task, rt, ctx) {
|
|
91
|
+
const cp = task.checkpoint;
|
|
92
|
+
const model = rt.models.resolve(cp.model);
|
|
93
|
+
if (model === undefined)
|
|
94
|
+
return failStep(task, "no_model", `model ${cp.model.provider}/${cp.model.modelId} unavailable`);
|
|
95
|
+
const derived = await derive(task, cp, rt, ctx);
|
|
96
|
+
if (estimate(derived.messages) > model.contextWindow - model.maxTokens)
|
|
97
|
+
return overflow(task, rt);
|
|
98
|
+
const attempt = cp.attempt + 1;
|
|
99
|
+
await rt.commit((tx) => {
|
|
100
|
+
tx.checkpoint({ ...cp, phase: "requesting", attempt });
|
|
101
|
+
tx.emit({ type: "generation.started", taskId: task.id, attempt });
|
|
102
|
+
}, ctx); // before the effect
|
|
103
|
+
const { terminal, deferred } = await stream(cp, model, derived.messages, rt, ctx);
|
|
104
|
+
if (deferred !== undefined) {
|
|
105
|
+
const pollAt = rt.now() + (deferred.pollAfterMs ?? 5000);
|
|
106
|
+
return {
|
|
107
|
+
next: (tx) => {
|
|
108
|
+
tx.emit({ type: "generation.deferred", taskId: task.id, pollAt });
|
|
109
|
+
return { ...cp, phase: "deferred", attempt, handle: toStored(deferred), pollAt };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return classify(task, { ...cp, attempt }, terminal, rt, ctx);
|
|
114
|
+
},
|
|
115
|
+
// Only reached by the scheduler after a crash: the call may have happened and there
|
|
116
|
+
// is no result lookup. Count it as a failed attempt and retry per policy.
|
|
117
|
+
async requesting(task, rt) {
|
|
118
|
+
const cp = task.checkpoint;
|
|
119
|
+
const decision = retryDecision(cp, null, rt.now());
|
|
120
|
+
if (decision.kind === "fail")
|
|
121
|
+
return failStep(task, decision.reason, "interrupted");
|
|
122
|
+
return {
|
|
123
|
+
next: (tx, current) => {
|
|
124
|
+
tx.appendEntry(current.conversationId, {
|
|
125
|
+
kind: "pi.usage",
|
|
126
|
+
data: { attempt: cp.attempt, error: "interrupted" },
|
|
127
|
+
});
|
|
128
|
+
tx.emit({
|
|
129
|
+
type: "generation.retrying",
|
|
130
|
+
taskId: current.id,
|
|
131
|
+
attempt: cp.attempt,
|
|
132
|
+
retryAt: decision.untilMs,
|
|
133
|
+
error: "interrupted",
|
|
134
|
+
});
|
|
135
|
+
return { ...cp, phase: "retrying", untilMs: decision.untilMs, lastError: "interrupted" };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
// Durable backoff, then back to step 2. Resets the streaming view.
|
|
140
|
+
async retrying(task, rt, ctx) {
|
|
141
|
+
const cp = task.checkpoint;
|
|
142
|
+
await rt.sleep(cp.untilMs, ctx);
|
|
143
|
+
const { untilMs: _u, lastError: _e, ...prep } = cp;
|
|
144
|
+
return {
|
|
145
|
+
next: (tx, current) => {
|
|
146
|
+
tx.sticky(current.conversationId).turn.message = undefined;
|
|
147
|
+
return { ...prep, phase: "prepared" };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
// Provider-side async. Poll; not the retry backoff. Same after a crash: the handle is durable.
|
|
152
|
+
async deferred(task, rt, ctx) {
|
|
153
|
+
const cp = task.checkpoint;
|
|
154
|
+
const model = rt.models.resolve(cp.model);
|
|
155
|
+
if (model === undefined)
|
|
156
|
+
return failStep(task, "no_model", "model disappeared");
|
|
157
|
+
await rt.sleep(cp.pollAt, ctx);
|
|
158
|
+
const result = await rt.models.fetchDeferred(model, cp.handle, ctx);
|
|
159
|
+
if ("deferred" in result && result.deferred !== undefined) {
|
|
160
|
+
const pollAt = rt.now() + (result.deferred.pollAfterMs ?? 5000);
|
|
161
|
+
return {
|
|
162
|
+
next: (tx) => {
|
|
163
|
+
tx.emit({ type: "generation.deferred", taskId: task.id, pollAt });
|
|
164
|
+
return { ...cp, handle: toStored(result.deferred), pollAt };
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const message = result;
|
|
169
|
+
await rt.commit((tx, current) => {
|
|
170
|
+
tx.sticky(current.conversationId).turn.message = toStored(message);
|
|
171
|
+
}, ctx);
|
|
172
|
+
const { handle: _handle, pollAt: _pollAt, ...prep } = cp;
|
|
173
|
+
return classify(task, prep, message, rt, ctx);
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
async abort(task, rt, ctx) {
|
|
177
|
+
const cp = task.checkpoint;
|
|
178
|
+
if (cp?.phase === "deferred") {
|
|
179
|
+
const model = rt.models.resolve(cp.model);
|
|
180
|
+
if (model)
|
|
181
|
+
await rt.models.cancelDeferred(model, cp.handle, ctx).catch(() => { });
|
|
182
|
+
}
|
|
183
|
+
return async (tx, current) => {
|
|
184
|
+
const partial = tx.sticky(current.conversationId).turn.message;
|
|
185
|
+
let assistant;
|
|
186
|
+
// Display-only: the partial goes in `data`, never in `model`, so it cannot enter a request.
|
|
187
|
+
if (partial && partial.content.length > 0)
|
|
188
|
+
assistant = tx.appendEntry(current.conversationId, {
|
|
189
|
+
kind: "pi.assistant",
|
|
190
|
+
data: {
|
|
191
|
+
attempt: cp?.attempt ?? 0,
|
|
192
|
+
display: { ...toStored(partial), stopReason: "aborted" },
|
|
193
|
+
reason: "aborted",
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
tx.sticky(current.conversationId).turn = { tools: [] };
|
|
197
|
+
await tx.resolveInputs(task.input.inputs, { status: "unanswered", reason: "aborted" });
|
|
198
|
+
tx.emit({ type: "turn.ended", inputs: task.input.inputs, status: "unanswered", reason: "aborted" });
|
|
199
|
+
return assistant === undefined ? {} : { assistant };
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Request derivation and streaming
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
async function derive(task, cp, rt, ctx) {
|
|
207
|
+
const { messages } = await rt.context(task.conversationId, cp.cutoff, ctx);
|
|
208
|
+
let request = { messages };
|
|
209
|
+
await rt.hooks.each(ctx, (h, api) => h.beforeRequest?.(request, { ...api, cutoff: cp.cutoff }, ctx), (value) => {
|
|
210
|
+
if (value !== undefined)
|
|
211
|
+
request = value;
|
|
212
|
+
});
|
|
213
|
+
return request;
|
|
214
|
+
}
|
|
215
|
+
/** Stream, coalescing frames into the turn view. Flush on size or time. */
|
|
216
|
+
async function stream(cp, model, messages, rt, ctx) {
|
|
217
|
+
const encoder = new AssistantMessageFrameEncoder();
|
|
218
|
+
let pending = [];
|
|
219
|
+
let pendingBytes = 0;
|
|
220
|
+
let lastFlush = rt.now();
|
|
221
|
+
let terminal;
|
|
222
|
+
let deferred;
|
|
223
|
+
let flushedContent = false;
|
|
224
|
+
const flush = async () => {
|
|
225
|
+
const batch = pending;
|
|
226
|
+
if (batch.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
pending = [];
|
|
229
|
+
pendingBytes = 0;
|
|
230
|
+
lastFlush = rt.now();
|
|
231
|
+
if (batch.some((frame) => "delta" in frame))
|
|
232
|
+
flushedContent = true;
|
|
233
|
+
await rt.commit((tx, current) => {
|
|
234
|
+
const turn = tx.sticky(current.conversationId).turn;
|
|
235
|
+
for (const f of batch)
|
|
236
|
+
applyFrame(turn, f);
|
|
237
|
+
}, ctx);
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
const iterator = rt.models
|
|
241
|
+
.stream(model, { messages, thinkingLevel: cp.thinkingLevel }, ctx)[Symbol.asyncIterator]();
|
|
242
|
+
let pull = iterator.next();
|
|
243
|
+
for (;;) {
|
|
244
|
+
const selected = pending.length === 0
|
|
245
|
+
? { kind: "event", result: await pull }
|
|
246
|
+
: await Promise.race([
|
|
247
|
+
pull.then((result) => ({ kind: "event", result })),
|
|
248
|
+
rt.sleep(lastFlush + 100, ctx).then(() => ({ kind: "flush" })),
|
|
249
|
+
]);
|
|
250
|
+
if (selected.kind === "flush") {
|
|
251
|
+
await flush();
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (selected.result.done)
|
|
255
|
+
break;
|
|
256
|
+
const event = selected.result.value;
|
|
257
|
+
if (event.type === "done") {
|
|
258
|
+
encoder.encode(event);
|
|
259
|
+
if (event.reason === "deferred" && event.message.deferred !== undefined)
|
|
260
|
+
deferred = event.message.deferred;
|
|
261
|
+
else
|
|
262
|
+
terminal = event.message;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
if (event.type === "error") {
|
|
266
|
+
encoder.encode(event);
|
|
267
|
+
terminal = event.error;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
const frame = encoder.encode(event);
|
|
271
|
+
pull = iterator.next();
|
|
272
|
+
if (frame === undefined)
|
|
273
|
+
continue;
|
|
274
|
+
pending.push(frame);
|
|
275
|
+
pendingBytes += "delta" in frame ? frame.delta.length : 64;
|
|
276
|
+
if (pendingBytes >= 256 || (!flushedContent && "delta" in frame))
|
|
277
|
+
await flush();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (ctx.abortSignal?.aborted)
|
|
282
|
+
throw error;
|
|
283
|
+
terminal = {
|
|
284
|
+
role: "assistant",
|
|
285
|
+
content: [],
|
|
286
|
+
api: model.api,
|
|
287
|
+
provider: model.provider,
|
|
288
|
+
model: model.id,
|
|
289
|
+
usage: emptyUsage(),
|
|
290
|
+
stopReason: "error",
|
|
291
|
+
errorMessage: String(error),
|
|
292
|
+
timestamp: rt.now(),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (pending.length > 0)
|
|
296
|
+
await flush();
|
|
297
|
+
if (terminal === undefined && deferred === undefined)
|
|
298
|
+
throw new Error("stream ended without a terminal message");
|
|
299
|
+
return { terminal, deferred };
|
|
300
|
+
}
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Steps 3–5: classify the terminal message.
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
async function classify(task, cp, message, rt, ctx) {
|
|
305
|
+
await rt.hooks.each(ctx, (h, api) => h.afterResponse?.(message, { ...api, attempt: cp.attempt }, ctx));
|
|
306
|
+
if (message.stopReason === "error") {
|
|
307
|
+
const decision = retryDecision(cp, message, rt.now());
|
|
308
|
+
if (decision.kind === "retry") {
|
|
309
|
+
return {
|
|
310
|
+
next: (tx, current) => {
|
|
311
|
+
tx.appendEntry(current.conversationId, {
|
|
312
|
+
kind: "pi.usage",
|
|
313
|
+
data: {
|
|
314
|
+
attempt: cp.attempt,
|
|
315
|
+
usage: toStored(message.usage),
|
|
316
|
+
error: message.errorMessage ?? "provider error",
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
tx.emit({
|
|
320
|
+
type: "generation.retrying",
|
|
321
|
+
taskId: current.id,
|
|
322
|
+
attempt: cp.attempt,
|
|
323
|
+
retryAt: decision.untilMs,
|
|
324
|
+
error: message.errorMessage ?? "provider error",
|
|
325
|
+
});
|
|
326
|
+
return {
|
|
327
|
+
...cp,
|
|
328
|
+
phase: "retrying",
|
|
329
|
+
untilMs: decision.untilMs,
|
|
330
|
+
lastError: message.errorMessage ?? "provider error",
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return { done: terminalError(task, cp, message, decision.reason) };
|
|
336
|
+
}
|
|
337
|
+
if (message.stopReason === "aborted")
|
|
338
|
+
return { done: terminalError(task, cp, message, "provider") };
|
|
339
|
+
const calls = message.content.filter((c) => c.type === "toolCall");
|
|
340
|
+
const stored = toStored(message);
|
|
341
|
+
if (calls.length > 0) {
|
|
342
|
+
return {
|
|
343
|
+
done: async (tx, current) => {
|
|
344
|
+
const c = current.conversationId;
|
|
345
|
+
const collapseThrough = await thresholdCollapseThrough(tx, c, message); // read BEFORE the append
|
|
346
|
+
const assistant = tx.appendEntry(c, {
|
|
347
|
+
kind: "pi.assistant",
|
|
348
|
+
model: [stored],
|
|
349
|
+
data: { attempt: cp.attempt },
|
|
350
|
+
});
|
|
351
|
+
const turn = tx.sticky(c).turn;
|
|
352
|
+
turn.message = undefined;
|
|
353
|
+
turn.tools = calls.map((call) => ({
|
|
354
|
+
callId: call.id,
|
|
355
|
+
name: call.name,
|
|
356
|
+
args: toStored(call.arguments),
|
|
357
|
+
status: "pending",
|
|
358
|
+
}));
|
|
359
|
+
const tools = calls.map((call, index) => tx.createTask({ kind: "pi.tool", input: { assistant, call: toStored(call), offered: cp.tools, index } }));
|
|
360
|
+
const postTools = tx.createTask({
|
|
361
|
+
kind: "pi.post_tools",
|
|
362
|
+
after: tools,
|
|
363
|
+
input: { inputs: task.input.inputs, assistant, tools },
|
|
364
|
+
});
|
|
365
|
+
if (collapseThrough !== undefined)
|
|
366
|
+
tx.createTask({ kind: "pi.collapse", input: { reason: "threshold", through: collapseThrough } });
|
|
367
|
+
tx.emit({ type: "generation.completed", taskId: current.id, entry: assistant, toolCalls: calls.length });
|
|
368
|
+
return { status: "completed", result: { assistant, tools, postTools } };
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
let continueText;
|
|
373
|
+
await rt.hooks.each(ctx, (h, api) => h.onYield?.(message, api, ctx), (value) => {
|
|
374
|
+
if (value === undefined)
|
|
375
|
+
return;
|
|
376
|
+
continueText = value.continue;
|
|
377
|
+
return true;
|
|
378
|
+
});
|
|
379
|
+
return {
|
|
380
|
+
done: async (tx, current) => {
|
|
381
|
+
const c = current.conversationId;
|
|
382
|
+
const collapseThrough = await thresholdCollapseThrough(tx, c, message); // read BEFORE the append
|
|
383
|
+
const head = await tx.newestEntry(c, { withHead: true });
|
|
384
|
+
const assistant = tx.appendEntry(c, { kind: "pi.assistant", model: [stored], data: { attempt: cp.attempt } });
|
|
385
|
+
tx.emit({ type: "generation.completed", taskId: current.id, entry: assistant, toolCalls: 0 });
|
|
386
|
+
tx.sticky(c).turn = { tools: [] };
|
|
387
|
+
if (collapseThrough !== undefined)
|
|
388
|
+
tx.createTask({ kind: "pi.collapse", input: { reason: "threshold", through: collapseThrough } });
|
|
389
|
+
const { triggers, terminated } = await tx.boundary(c, "final", head?.id);
|
|
390
|
+
if (continueText !== undefined && triggers.length === 0 && !terminated) {
|
|
391
|
+
tx.appendEntry(c, {
|
|
392
|
+
kind: "pi.user",
|
|
393
|
+
model: [{ role: "user", content: continueText, timestamp: rt.now() }],
|
|
394
|
+
data: { continuation: true, from: assistant },
|
|
395
|
+
});
|
|
396
|
+
const successor = tx.createTask({ kind: "pi.generation", input: { inputs: task.input.inputs } });
|
|
397
|
+
return { status: "completed", result: { assistant, tools: [], successor } };
|
|
398
|
+
}
|
|
399
|
+
await tx.resolveInputs(task.input.inputs, { status: "done", answer: assistant });
|
|
400
|
+
tx.emit({ type: "turn.ended", inputs: task.input.inputs, status: "done", answer: assistant });
|
|
401
|
+
if (triggers.length > 0) {
|
|
402
|
+
const successor = tx.createTask({ kind: "pi.generation", input: { inputs: triggers } });
|
|
403
|
+
tx.emit({ type: "turn.started", inputs: triggers });
|
|
404
|
+
return { status: "completed", result: { assistant, tools: [], successor } };
|
|
405
|
+
}
|
|
406
|
+
return { status: "completed", result: { assistant, tools: [] } };
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/** Provider error / aborted stop: a display-only assistant entry (no `model`), inputs unanswered. */
|
|
411
|
+
function terminalError(task, cp, message, reason) {
|
|
412
|
+
return async (tx, current) => {
|
|
413
|
+
const head = await tx.newestEntry(current.conversationId, { withHead: true });
|
|
414
|
+
const assistant = tx.appendEntry(current.conversationId, {
|
|
415
|
+
kind: "pi.assistant",
|
|
416
|
+
data: {
|
|
417
|
+
attempt: cp.attempt,
|
|
418
|
+
display: toStored(message),
|
|
419
|
+
reason: message.stopReason === "aborted" ? "aborted" : "error",
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
tx.emit({
|
|
423
|
+
type: "generation.failed",
|
|
424
|
+
taskId: current.id,
|
|
425
|
+
reason,
|
|
426
|
+
detail: message.errorMessage ?? "provider error",
|
|
427
|
+
entry: assistant,
|
|
428
|
+
});
|
|
429
|
+
await settleFailedTurn(tx, current, task.input.inputs, message.errorMessage ?? "provider error", head?.id);
|
|
430
|
+
return fail(reason, message.errorMessage ?? "provider error", assistant);
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
async function settleFailedTurn(tx, current, inputs, detail, headBoundary) {
|
|
434
|
+
tx.sticky(current.conversationId).turn = { tools: [] };
|
|
435
|
+
await tx.resolveInputs(inputs, { status: "unanswered", reason: "failed", detail });
|
|
436
|
+
tx.emit({ type: "turn.ended", inputs: [...inputs], status: "unanswered", reason: "failed", detail });
|
|
437
|
+
const { triggers } = await tx.boundary(current.conversationId, "final", headBoundary);
|
|
438
|
+
if (triggers.length > 0) {
|
|
439
|
+
tx.createTask({ kind: "pi.generation", conversationId: current.conversationId, input: { inputs: triggers } });
|
|
440
|
+
tx.emit({ type: "turn.started", inputs: triggers });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
export function retryDecision(cp, message, now) {
|
|
444
|
+
if (message !== null && !isRetryableAssistantError(message))
|
|
445
|
+
return { kind: "fail", reason: "provider" };
|
|
446
|
+
if (!cp.retry.enabled)
|
|
447
|
+
return { kind: "fail", reason: "provider" };
|
|
448
|
+
if (cp.attempt > cp.retry.maxRetries)
|
|
449
|
+
return { kind: "fail", reason: "retries_exhausted" };
|
|
450
|
+
const delay = cp.retry.baseDelayMs * 2 ** Math.max(0, cp.attempt - 1);
|
|
451
|
+
const safeDelay = Number.isSafeInteger(delay) ? delay : Number.MAX_SAFE_INTEGER;
|
|
452
|
+
return { kind: "retry", untilMs: now + Math.min(safeDelay, cp.retry.maxAgentDelayMs ?? 60_000) };
|
|
453
|
+
}
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
// Collapse triggers
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
/** Read-side of threshold collapse: computed before the assistant is appended, applied after. */
|
|
458
|
+
async function thresholdCollapseThrough(tx, conversationId, message) {
|
|
459
|
+
if ((await tx.tasks({ conversationId, kind: "pi.collapse", status: ["pending", "running"] })).length > 0)
|
|
460
|
+
return undefined;
|
|
461
|
+
const state = tx.rewindable(conversationId);
|
|
462
|
+
if (state.threshold <= 0)
|
|
463
|
+
return undefined;
|
|
464
|
+
const { entries, messages } = await tx.context(conversationId);
|
|
465
|
+
const used = Math.max((message.usage?.input ?? 0) + (message.usage?.output ?? 0), estimate([...messages, toStored(message)]));
|
|
466
|
+
if (used <= state.threshold)
|
|
467
|
+
return undefined;
|
|
468
|
+
return chooseThrough(entries, state.keepRecent);
|
|
469
|
+
}
|
|
470
|
+
function overflow(task, rt) {
|
|
471
|
+
return {
|
|
472
|
+
done: async (tx, current) => {
|
|
473
|
+
const c = current.conversationId;
|
|
474
|
+
const state = tx.rewindable(c);
|
|
475
|
+
const { entries, head } = await tx.context(c);
|
|
476
|
+
const through = chooseThrough(entries, state.keepRecent);
|
|
477
|
+
tx.sticky(c).turn = { tools: [] };
|
|
478
|
+
if (through === undefined) {
|
|
479
|
+
await tx.write(c, {
|
|
480
|
+
kind: "pi.notice",
|
|
481
|
+
model: [{ role: "user", content: "Context too large; nothing to compact.", timestamp: rt.now() }],
|
|
482
|
+
});
|
|
483
|
+
tx.emit({
|
|
484
|
+
type: "generation.failed",
|
|
485
|
+
taskId: current.id,
|
|
486
|
+
reason: "overflow",
|
|
487
|
+
detail: "request exceeds context window and nothing is collapsible",
|
|
488
|
+
});
|
|
489
|
+
await settleFailedTurn(tx, current, task.input.inputs, "overflow", head?.id);
|
|
490
|
+
return fail("overflow", "request exceeds context window and nothing is collapsible");
|
|
491
|
+
}
|
|
492
|
+
const collapse = tx.createTask({ kind: "pi.collapse", input: { reason: "overflow", through } });
|
|
493
|
+
tx.createTask({ kind: "pi.generation", after: [collapse], input: { inputs: task.input.inputs } });
|
|
494
|
+
tx.emit({
|
|
495
|
+
type: "generation.failed",
|
|
496
|
+
taskId: current.id,
|
|
497
|
+
reason: "overflow",
|
|
498
|
+
detail: `collapsing through ${through}`,
|
|
499
|
+
});
|
|
500
|
+
return fail("overflow", `collapsing through ${through}`);
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
//# sourceMappingURL=generation.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Kind, KindConfig, ProcessSpec } from "../types.ts";
|
|
2
|
+
export type JobInput = {
|
|
3
|
+
[K in keyof ProcessSpec]: ProcessSpec[K];
|
|
4
|
+
} & {
|
|
5
|
+
notify: boolean;
|
|
6
|
+
rerun: boolean;
|
|
7
|
+
every?: number;
|
|
8
|
+
notBefore?: number;
|
|
9
|
+
};
|
|
10
|
+
export type JobCheckpoint = {
|
|
11
|
+
phase: "waiting";
|
|
12
|
+
untilMs: number;
|
|
13
|
+
occurrence: number;
|
|
14
|
+
} | {
|
|
15
|
+
phase: "spawning";
|
|
16
|
+
key: string;
|
|
17
|
+
occurrence: number;
|
|
18
|
+
} | {
|
|
19
|
+
phase: "running";
|
|
20
|
+
key: string;
|
|
21
|
+
occurrence: number;
|
|
22
|
+
};
|
|
23
|
+
export type JobOutput = {
|
|
24
|
+
stdout?: string;
|
|
25
|
+
stderr?: string;
|
|
26
|
+
droppedStdout?: number;
|
|
27
|
+
droppedStderr?: number;
|
|
28
|
+
exitCode?: number;
|
|
29
|
+
occurrence?: number;
|
|
30
|
+
};
|
|
31
|
+
export type JobResult = {
|
|
32
|
+
exitCode: number;
|
|
33
|
+
occurrences: number;
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
};
|
|
37
|
+
export type JobFailure = {
|
|
38
|
+
reason: "spawn" | "interrupted";
|
|
39
|
+
detail: string;
|
|
40
|
+
};
|
|
41
|
+
export declare const job: Readonly<Kind<JobInput, JobCheckpoint, JobResult, JobFailure, {
|
|
42
|
+
killed: boolean;
|
|
43
|
+
}, object, KindConfig<import("../types.ts").ConfigShape, import("../types.ts").ConfigShape>, JobOutput, import("../types.ts").TaskTx>>;
|
|
44
|
+
//# sourceMappingURL=job.d.ts.map
|