@springbrand/agent-runtime 0.1.3-alpha.0 → 0.1.3-alpha.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.
@@ -2,8 +2,10 @@ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
2
  import {
3
3
  createModels,
4
4
  createProvider,
5
+ isRetryableAssistantError,
5
6
  lazyStream,
6
7
  type Api,
8
+ type AssistantMessage,
7
9
  type AssistantMessageEvent,
8
10
  type Model,
9
11
  type MutableModels,
@@ -35,6 +37,12 @@ import {
35
37
  import {
36
38
  openaiCodexProvider,
37
39
  } from "@earendil-works/pi-ai/providers/openai-codex";
40
+ import {
41
+ cloudflareStreams,
42
+ } from "@earendil-works/pi-ai/providers/cloudflare-stream";
43
+ import {
44
+ ChatStreamStalledError,
45
+ } from "agents/chat";
38
46
  import type {
39
47
  RuntimeModelEndpoint,
40
48
  RuntimeModelProtocol,
@@ -50,46 +58,216 @@ const CATALOGS = {
50
58
  } satisfies Record<RuntimeModelProtocol, readonly Model<Api>[]>;
51
59
 
52
60
  const PROVIDER_MAX_RETRIES = 2;
53
- const MODEL_STREAM_STALL_TIMEOUT_MS = 120_000;
61
+ export const MODEL_STREAM_STALL_TIMEOUT_MS = 60_000;
62
+ export const MODEL_STREAM_STALL_MESSAGE =
63
+ `Chat stream stalled: no activity for ${MODEL_STREAM_STALL_TIMEOUT_MS}ms; the turn was aborted by the stall watchdog.`;
64
+ const MODEL_STREAM_STALL_DETAILS_PREFIX = `${MODEL_STREAM_STALL_MESSAGE}\n`;
65
+
66
+ export interface ModelStreamStallDetails {
67
+ lastMeaningfulActivityAt: number;
68
+ lastMeaningfulActivityType:
69
+ | AssistantMessageEvent["type"]
70
+ | "model_stream_started";
71
+ idleMs: number;
72
+ }
73
+
74
+ export function isModelStreamStallMessage(message?: string): message is string {
75
+ return message === MODEL_STREAM_STALL_MESSAGE ||
76
+ message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX) === true;
77
+ }
78
+
79
+ export function readModelStreamStallDetails(
80
+ message?: string,
81
+ ): ModelStreamStallDetails | undefined {
82
+ if (!message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX)) return;
83
+ try {
84
+ const value = JSON.parse(
85
+ message.slice(MODEL_STREAM_STALL_DETAILS_PREFIX.length),
86
+ ) as Partial<ModelStreamStallDetails>;
87
+ if (
88
+ typeof value.lastMeaningfulActivityAt !== "number" ||
89
+ typeof value.lastMeaningfulActivityType !== "string" ||
90
+ typeof value.idleMs !== "number"
91
+ ) return;
92
+ return value as ModelStreamStallDetails;
93
+ } catch {
94
+ return;
95
+ }
96
+ }
97
+
98
+ export function isRecoverableAssistantError(
99
+ message: AssistantMessage,
100
+ ): boolean {
101
+ if (
102
+ message.stopReason === "error" &&
103
+ /thought[_ ]signature/i.test(message.errorMessage ?? "")
104
+ ) return false;
105
+ return isRetryableAssistantError(message) ||
106
+ (message.stopReason === "error" &&
107
+ /upstream http\/2 stream failed/i.test(message.errorMessage ?? ""));
108
+ }
109
+
110
+ function pdfPayload(payload: unknown, api: Api): unknown {
111
+ if (Array.isArray(payload)) {
112
+ return payload.map((value) => pdfPayload(value, api));
113
+ }
114
+ if (typeof payload !== "object" || payload === null) return payload;
115
+
116
+ const value = payload as Record<string, unknown>;
117
+ if (api === "anthropic-messages" && value.type === "image") {
118
+ const source = value.source as Record<string, unknown> | undefined;
119
+ if (source?.media_type === "application/pdf") {
120
+ return { ...value, type: "document" };
121
+ }
122
+ }
123
+ if (api === "openai-completions" && value.type === "image_url") {
124
+ const image = value.image_url as Record<string, unknown> | undefined;
125
+ if (
126
+ typeof image?.url === "string" &&
127
+ image.url.startsWith("data:application/pdf;base64,")
128
+ ) {
129
+ return {
130
+ type: "file",
131
+ file: { filename: "workspace.pdf", file_data: image.url },
132
+ };
133
+ }
134
+ }
135
+ if (api === "openai-codex-responses" && value.type === "input_image") {
136
+ if (
137
+ typeof value.image_url === "string" &&
138
+ value.image_url.startsWith("data:application/pdf;base64,")
139
+ ) {
140
+ return {
141
+ type: "input_file",
142
+ filename: "workspace.pdf",
143
+ file_data: value.image_url,
144
+ };
145
+ }
146
+ }
147
+
148
+ return Object.fromEntries(
149
+ Object.entries(value).map(([key, child]) => [
150
+ key,
151
+ pdfPayload(child, api),
152
+ ]),
153
+ );
154
+ }
155
+
156
+ function meaningfulModelProgress(
157
+ event: AssistantMessageEvent,
158
+ streamedContent: Set<string>,
159
+ ): AssistantMessageEvent["type"] | undefined {
160
+ switch (event.type) {
161
+ case "text_delta":
162
+ case "thinking_delta": {
163
+ if (!event.delta.trim()) return undefined;
164
+ streamedContent.add(`${event.type}:${event.contentIndex}`);
165
+ return event.type;
166
+ }
167
+ case "text_end":
168
+ if (streamedContent.has(`text_delta:${event.contentIndex}`)) return;
169
+ return event.content.trim() ? event.type : undefined;
170
+ case "thinking_end":
171
+ if (streamedContent.has(`thinking_delta:${event.contentIndex}`)) return;
172
+ return event.content.trim() ? event.type : undefined;
173
+ case "toolcall_end":
174
+ return event.type;
175
+ default:
176
+ return undefined;
177
+ }
178
+ }
54
179
 
55
180
  async function* stopStalledModelStream(
56
181
  source: AsyncIterable<AssistantMessageEvent>,
57
182
  watchdog: AbortController,
58
183
  ): AsyncGenerator<AssistantMessageEvent> {
59
184
  const iterator = source[Symbol.asyncIterator]();
60
- while (true) {
61
- let timer: ReturnType<typeof setTimeout> | undefined;
62
- const next = await Promise.race([
63
- iterator.next(),
64
- new Promise<never>((_, reject) => {
65
- timer = setTimeout(() => {
66
- const error = new Error(
67
- `Model stream stalled for ${MODEL_STREAM_STALL_TIMEOUT_MS} ms`,
68
- );
69
- watchdog.abort(error);
70
- reject(error);
71
- }, MODEL_STREAM_STALL_TIMEOUT_MS);
72
- }),
73
- ]).finally(() => {
74
- if (timer !== undefined) clearTimeout(timer);
75
- });
76
- if (next.done) {
77
- throw new Error("Model stream ended without a terminal event");
185
+ let timer: ReturnType<typeof setTimeout> | undefined;
186
+ let stalled = false;
187
+ let stallError: ChatStreamStalledError | undefined;
188
+ let idleWaitMs = 0;
189
+ const streamedContent = new Set<string>();
190
+ let lastMeaningfulActivityAt = Date.now();
191
+ let lastMeaningfulActivityType:
192
+ ModelStreamStallDetails["lastMeaningfulActivityType"] =
193
+ "model_stream_started";
194
+ const stop = (idleMs: number) => {
195
+ stalled = true;
196
+ stallError = new ChatStreamStalledError(
197
+ MODEL_STREAM_STALL_DETAILS_PREFIX + JSON.stringify({
198
+ lastMeaningfulActivityAt,
199
+ lastMeaningfulActivityType,
200
+ idleMs,
201
+ } satisfies ModelStreamStallDetails),
202
+ );
203
+ watchdog.abort(stallError);
204
+ return stallError;
205
+ };
206
+ const arm = (
207
+ delayMs: number,
208
+ waitStartedAt: number,
209
+ ) => new Promise<never>((_, reject) => {
210
+ timer = setTimeout(() => {
211
+ reject(stop(idleWaitMs + Date.now() - waitStartedAt));
212
+ }, delayMs);
213
+ });
214
+ try {
215
+ while (true) {
216
+ const waitStartedAt = Date.now();
217
+ const nextPromise = iterator.next();
218
+ nextPromise.catch(() => {});
219
+ let next: IteratorResult<AssistantMessageEvent>;
220
+ try {
221
+ next = await Promise.race([
222
+ nextPromise,
223
+ arm(
224
+ Math.max(0, MODEL_STREAM_STALL_TIMEOUT_MS - idleWaitMs),
225
+ waitStartedAt,
226
+ ),
227
+ ]);
228
+ } catch (error) {
229
+ if (stalled) throw stallError;
230
+ throw error;
231
+ } finally {
232
+ clearTimeout(timer);
233
+ timer = undefined;
234
+ }
235
+ if (stalled) throw stallError;
236
+ if (next.done) break;
237
+ const event = next.value;
238
+ if (event.type === "done" || event.type === "error") {
239
+ yield event;
240
+ return;
241
+ }
242
+ idleWaitMs += Date.now() - waitStartedAt;
243
+ const progress = meaningfulModelProgress(event, streamedContent);
244
+ if (progress) {
245
+ lastMeaningfulActivityAt = Date.now();
246
+ lastMeaningfulActivityType = progress;
247
+ idleWaitMs = 0;
248
+ }
249
+ yield event;
78
250
  }
79
- yield next.value;
80
- if (next.value.type === "done" || next.value.type === "error") return;
251
+ } finally {
252
+ clearTimeout(timer);
253
+ if (!stalled) await iterator.return?.().catch(() => {});
81
254
  }
255
+ throw new Error("Model stream ended without a terminal event");
82
256
  }
83
257
 
84
258
  /**
85
- * 给模型请求补上可中断的 provider 瞬时错误重试和空闲终止边界。
259
+ * 给模型请求补上可中断的空闲终止边界。
86
260
  *
87
261
  * Runtime Turn 和 SubAgent 在把 `Models.streamSimple` 交给 Pi 前调用;显式传入的重试次数优先。
88
262
  *
89
- * Pi 0.83 默认 `maxRetries` 0,且流无事件时会无限等待。这里补 2 次默认重试,
90
- * 并在连续 120 秒没有模型事件时中止 provider、返回明确失败终态。
263
+ * 调用方可以覆盖默认的 2 Provider 重试;主 Turn 传 0,由 Submission
264
+ * 统一持有恢复预算。连续 60 秒没有可展示进展时中止 provider
91
265
  */
92
- export function withProviderRetry(streamFn: StreamFn): StreamFn {
266
+ export function withProviderRetry(
267
+ streamFn: StreamFn,
268
+ defaultMaxRetries = PROVIDER_MAX_RETRIES,
269
+ defaultSessionId?: string,
270
+ ): StreamFn {
93
271
  return (model, context, options) =>
94
272
  lazyStream(model, async () => {
95
273
  const watchdog = new AbortController();
@@ -98,7 +276,13 @@ export function withProviderRetry(streamFn: StreamFn): StreamFn {
98
276
  signal: options?.signal
99
277
  ? AbortSignal.any([options.signal, watchdog.signal])
100
278
  : watchdog.signal,
101
- maxRetries: options?.maxRetries ?? PROVIDER_MAX_RETRIES,
279
+ maxRetries: options?.maxRetries ?? defaultMaxRetries,
280
+ sessionId: options?.sessionId ?? defaultSessionId,
281
+ onPayload: async (payload, activeModel) =>
282
+ pdfPayload(
283
+ await options?.onPayload?.(payload, activeModel) ?? payload,
284
+ activeModel.api,
285
+ ),
102
286
  });
103
287
  return stopStalledModelStream(source, watchdog);
104
288
  });
@@ -188,8 +372,14 @@ function configuredModel(
188
372
  api: apiFor(endpoint.protocol),
189
373
  provider: providerId(endpoint, index),
190
374
  baseUrl: endpoint.baseURL,
191
- ...(endpoint.protocol === "openrouter-chat" && compat
192
- ? { compat }
375
+ ...(endpoint.protocol === "openrouter-chat"
376
+ ? {
377
+ compat: {
378
+ ...compat,
379
+ sendSessionAffinityHeaders: true,
380
+ sessionAffinityFormat: "openrouter" as const,
381
+ },
382
+ }
193
383
  : {}),
194
384
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
195
385
  };
@@ -265,7 +455,9 @@ export function configurePiModels(
265
455
  models: endpoint.models.map((modelId) =>
266
456
  configuredModel(endpoint, index, modelId),
267
457
  ),
268
- api: piApiFor(endpoint.protocol),
458
+ // cloudflareStreams 在运行时把 baseURL 里的 {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}
459
+ // 占位符替换成真实值;如果 baseURL 不含占位符则直接透传,不影响现有端点。
460
+ api: cloudflareStreams(piApiFor(endpoint.protocol)),
269
461
  })
270
462
  );
271
463
  models.clearProviders();
@@ -29,6 +29,10 @@ import {
29
29
  import type { PiCanonicalMessageCommit } from "./execution";
30
30
  import type { PiRecoveredToolSettlement } from "./recovery";
31
31
  import type { RuntimeModelUsageEvent } from "../../kernel/bindings";
32
+ import {
33
+ isModelStreamStallMessage,
34
+ isRecoverableAssistantError,
35
+ } from "./models";
32
36
 
33
37
  /**
34
38
  * 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
@@ -196,6 +200,15 @@ function hostText(message: UserMessage | AssistantMessage): string {
196
200
  export class PiRuntimeTranscript {
197
201
  private readonly store: PiTranscriptStore;
198
202
 
203
+ private modelBranch() {
204
+ return this.store.branch().filter((entry) =>
205
+ entry.type !== "message" ||
206
+ entry.message.role !== "assistant" ||
207
+ (!isModelStreamStallMessage(entry.message.errorMessage) &&
208
+ !isRecoverableAssistantError(entry.message))
209
+ );
210
+ }
211
+
199
212
  /**
200
213
  * 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
201
214
  *
@@ -383,7 +396,7 @@ export class PiRuntimeTranscript {
383
396
  * 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
384
397
  */
385
398
  async canonicalMessages(): Promise<AgentMessage[]> {
386
- return this.store.branch().flatMap((entry) =>
399
+ return this.modelBranch().flatMap((entry) =>
387
400
  entry.type === "message" ? [entry.message] : []
388
401
  );
389
402
  }
@@ -449,7 +462,7 @@ export class PiRuntimeTranscript {
449
462
  onCompactionPersisted?: (event: RuntimeModelUsageEvent) => void;
450
463
  },
451
464
  ): Promise<AgentMessage[]> {
452
- const branch = this.store.branch();
465
+ const branch = this.modelBranch();
453
466
  const current = branch.flatMap((entry) =>
454
467
  entry.type === "message" ? [entry.message] : []
455
468
  );
@@ -616,10 +629,47 @@ export class PiRuntimeTranscript {
616
629
  async browserMessages(): Promise<UIMessage[]> {
617
630
  const result: UIMessage[] = [];
618
631
  const seenAssistantSubmissions = new Set<string>();
632
+ const entries = await this.storedMessages();
633
+ const assistantSubmissions = new Set(entries.flatMap((entry) =>
634
+ entry.submissionId && entry.message.role === "assistant"
635
+ ? [entry.submissionId]
636
+ : []
637
+ ));
638
+ let pendingFailedSubmissionId: string | undefined;
639
+ const flushFailedSubmission = () => {
640
+ if (!pendingFailedSubmissionId) return;
641
+ const submissionId = pendingFailedSubmissionId;
642
+ pendingFailedSubmissionId = undefined;
643
+ const submission = this.durability.findSubmissionProjection(submissionId);
644
+ if (submission?.status !== "error") return;
645
+ const completedAt = submission.completedAt ?? submission.createdAt;
646
+ result.push({
647
+ id: submission.assistantMessageId,
648
+ role: "assistant",
649
+ parts: [],
650
+ metadata: {
651
+ createdAt: submission.createdAt,
652
+ completedAt,
653
+ turnDurationMs: Math.max(
654
+ 0,
655
+ completedAt - submission.createdAt,
656
+ ),
657
+ turnStatus: "error",
658
+ ...(submission.error ? { error: submission.error } : {}),
659
+ },
660
+ });
661
+ seenAssistantSubmissions.add(submissionId);
662
+ };
619
663
  let activeAssistant:
620
664
  | { submissionId: string; index: number }
621
665
  | undefined;
622
- for (const entry of await this.storedMessages()) {
666
+ for (const entry of entries) {
667
+ if (
668
+ pendingFailedSubmissionId &&
669
+ entry.submissionId !== pendingFailedSubmissionId
670
+ ) {
671
+ flushFailedSubmission();
672
+ }
623
673
  if (entry.message.role === "user") {
624
674
  const body = this.durability.readUserSidecar(entry.id);
625
675
  result.push(userUIMessage(entry.message, {
@@ -629,6 +679,13 @@ export class PiRuntimeTranscript {
629
679
  ? JSON.parse(body) as UIUserSidecar
630
680
  : undefined,
631
681
  }));
682
+ if (
683
+ entry.submissionId &&
684
+ !assistantSubmissions.has(entry.submissionId) &&
685
+ !seenAssistantSubmissions.has(entry.submissionId)
686
+ ) {
687
+ pendingFailedSubmissionId = entry.submissionId;
688
+ }
632
689
  activeAssistant = undefined;
633
690
  continue;
634
691
  }
@@ -744,6 +801,7 @@ export class PiRuntimeTranscript {
744
801
  }
745
802
  }
746
803
  }
804
+ flushFailedSubmission();
747
805
  return result;
748
806
  }
749
807
 
@@ -38,6 +38,52 @@ function resolveDescription(tool: AiTool): string {
38
38
  return typeof raw === "string" ? raw : "";
39
39
  }
40
40
 
41
+ function modelContent(
42
+ output: unknown,
43
+ ): AgentToolResult<unknown>["content"] | null {
44
+ if (typeof output !== "object" || output === null) return null;
45
+ const modelOutput = output as { type?: unknown; value?: unknown };
46
+ if (
47
+ (modelOutput.type === "text" || modelOutput.type === "error-text") &&
48
+ typeof modelOutput.value === "string"
49
+ ) {
50
+ return [{ type: "text", text: modelOutput.value }];
51
+ }
52
+ if (modelOutput.type === "json") {
53
+ return [{ type: "text", text: serializeOutput(modelOutput.value).text }];
54
+ }
55
+ if (modelOutput.type !== "content" || !Array.isArray(modelOutput.value)) {
56
+ return null;
57
+ }
58
+
59
+ const content: AgentToolResult<unknown>["content"] = [];
60
+ for (const block of modelOutput.value) {
61
+ if (typeof block !== "object" || block === null) continue;
62
+ const item = block as {
63
+ type?: unknown;
64
+ text?: unknown;
65
+ data?: unknown;
66
+ mediaType?: unknown;
67
+ };
68
+ if (item.type === "text" && typeof item.text === "string") {
69
+ content.push({ type: "text", text: item.text });
70
+ continue;
71
+ }
72
+ if (
73
+ (item.type === "file-data" || item.type === "image-data") &&
74
+ typeof item.data === "string" &&
75
+ typeof item.mediaType === "string"
76
+ ) {
77
+ content.push({
78
+ type: "image",
79
+ data: item.data,
80
+ mimeType: item.mediaType,
81
+ });
82
+ }
83
+ }
84
+ return content.length > 0 ? content : null;
85
+ }
86
+
41
87
  /**
42
88
  * 把一个可执行的 ai-sdk Tool 转成 Pi AgentTool。
43
89
  *
@@ -91,8 +137,19 @@ export function aiToolToPi(
91
137
  context: undefined,
92
138
  });
93
139
  const details = options.details ? options.details(raw) : raw;
140
+ const toModelOutput = (tool as { toModelOutput?: unknown })
141
+ .toModelOutput;
142
+ const content = typeof toModelOutput === "function"
143
+ ? modelContent(await (toModelOutput as (input: {
144
+ input: unknown;
145
+ output: unknown;
146
+ }) => unknown)({ input: params, output: raw }))
147
+ : null;
94
148
  return {
95
- content: [{ type: "text", text: serializeOutput(details).text }],
149
+ content: content ?? [{
150
+ type: "text",
151
+ text: serializeOutput(details).text,
152
+ }],
96
153
  details,
97
154
  };
98
155
  },
@@ -2,7 +2,10 @@ import type {
2
2
  AgentTool,
3
3
  AgentToolResult,
4
4
  } from "@earendil-works/pi-agent-core";
5
- import { Type, type TSchema } from "@earendil-works/pi-ai";
5
+ import { Type, type Static, type TSchema } from "@earendil-works/pi-ai";
6
+ import { estimateStringTokens } from "agents/experimental/memory/utils";
7
+ import type { RuntimeMemoryPort } from "../../kernel/bindings";
8
+ import type { RuntimeMemoryProfile } from "../../kernel/profile";
6
9
  import { serializeOutput } from "../../lib/artifacts";
7
10
  import type { PiToolCandidate } from "./compiler";
8
11
  import { webSearchPiToolCandidate } from "./web-search";
@@ -42,13 +45,63 @@ const updatePlanParameters = Type.Object({
42
45
  Type.Literal("pending"),
43
46
  Type.Literal("in_progress"),
44
47
  Type.Literal("done"),
45
- ], { description: "Current status of this step." }),
48
+ ], {
49
+ description:
50
+ 'Current status. Use "done" for a finished step; never use "completed".',
51
+ }),
46
52
  }), {
47
53
  description:
48
54
  "The complete, ordered plan. Overwrites any previously reported plan.",
49
55
  }),
50
56
  });
51
57
 
58
+ type UpdatePlanArguments = Static<typeof updatePlanParameters>;
59
+
60
+ function normalizeStepStatus(
61
+ status: unknown,
62
+ ): UpdatePlanArguments["steps"][number]["status"] {
63
+ switch (status) {
64
+ case "done":
65
+ case "completed": // Anthropic models sometimes output "completed"
66
+ return "done";
67
+ case "in_progress":
68
+ case "inProgress": // camelCase variant
69
+ return "in_progress";
70
+ default: // covers "pending", "todo" (Google), and any other unknown value
71
+ return "pending";
72
+ }
73
+ }
74
+
75
+ export function normalizeUpdatePlanArguments(
76
+ input: unknown,
77
+ ): UpdatePlanArguments {
78
+ if (input === null || typeof input !== "object") {
79
+ return input as UpdatePlanArguments;
80
+ }
81
+ const value = input as Record<string, unknown>;
82
+ if (!Array.isArray(value.steps)) return input as UpdatePlanArguments;
83
+ return {
84
+ ...value,
85
+ steps: value.steps.map((step) => {
86
+ if (step === null || typeof step !== "object") return step;
87
+ const s = step as Record<string, unknown>;
88
+ return { ...s, status: normalizeStepStatus(s.status) };
89
+ }),
90
+ } as UpdatePlanArguments;
91
+ }
92
+
93
+ const setContextParameters = Type.Object({
94
+ label: Type.Union([
95
+ Type.Literal("memory"),
96
+ Type.Literal("preferences"),
97
+ ]),
98
+ content: Type.String(),
99
+ action: Type.Optional(Type.Union([
100
+ Type.Literal("replace"),
101
+ Type.Literal("append"),
102
+ ])),
103
+ });
104
+
52
105
  function result<T>(details: T): AgentToolResult<T> {
53
106
  return {
54
107
  content: [{ type: "text", text: serializeOutput(details).text }],
@@ -97,6 +150,7 @@ export function basePiToolCandidates(
97
150
  description:
98
151
  "Maintain the user-visible plan for the current task. Call this whenever a task involves 2 or more steps, and again every time the plan or a step's status changes. Always pass the FULL plan — it replaces the previous plan entirely (idempotent overwrite), so omitted steps disappear.",
99
152
  parameters: updatePlanParameters,
153
+ prepareArguments: normalizeUpdatePlanArguments,
100
154
  async execute(_toolCallId, input) {
101
155
  return result({
102
156
  ok: true,
@@ -108,3 +162,59 @@ export function basePiToolCandidates(
108
162
  ...(webSearch ? [webSearchPiToolCandidate(webSearch)] : []),
109
163
  ];
110
164
  }
165
+
166
+ /** Writable hot-memory Tool restored from Think Session context blocks. */
167
+ export function memoryPiToolCandidate(
168
+ memory: RuntimeMemoryPort,
169
+ profile: Pick<
170
+ RuntimeMemoryProfile,
171
+ "memoryTokens" | "preferencesTokens"
172
+ >,
173
+ ): PiToolCandidate {
174
+ const queues = new Map<string, Promise<void>>();
175
+ const tool: AgentTool<typeof setContextParameters> = {
176
+ name: "set_context",
177
+ label: "Set context",
178
+ description:
179
+ "Replace or append durable working context. Use memory for facts and active context; use preferences for tone, format, tools, and workflow preferences.",
180
+ parameters: setContextParameters,
181
+ async execute(_toolCallId, { label, content, action = "replace" }) {
182
+ const run = async () => {
183
+ const existing = action === "append"
184
+ ? await memory.get(label) ?? ""
185
+ : "";
186
+ const separator = existing && !content.startsWith("\n") ? "\n" : "";
187
+ const updated = `${existing}${separator}${content}`;
188
+ const maxTokens = label === "memory"
189
+ ? profile.memoryTokens
190
+ : profile.preferencesTokens;
191
+ const tokens = estimateStringTokens(updated);
192
+ if (tokens > maxTokens) {
193
+ throw new Error(
194
+ `Block "${label}" exceeds maxTokens: ${tokens} > ${maxTokens}`,
195
+ );
196
+ }
197
+ await memory.set(label, updated);
198
+ return result({ label, action, tokens, maxTokens });
199
+ };
200
+ const tail = queues.get(label) ?? Promise.resolve();
201
+ const output = tail.then(run);
202
+ const next = output.then(
203
+ () => {},
204
+ () => {},
205
+ );
206
+ queues.set(label, next);
207
+ void next.then(() => {
208
+ if (queues.get(label) === next) queues.delete(label);
209
+ });
210
+ return output;
211
+ },
212
+ };
213
+ return {
214
+ owner: "runtime-memory",
215
+ authorized: true,
216
+ requiredExecutionLevel: "safe",
217
+ source: "action",
218
+ tool,
219
+ };
220
+ }