@springbrand/agent-runtime 0.2.0-alpha.44 → 0.2.0-alpha.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.2.0-alpha.44",
3
+ "version": "0.2.0-alpha.46",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -19,7 +19,6 @@ import type {
19
19
  import { PiChunkEncoder } from "../../../pi/message";
20
20
  import {
21
21
  createPiModels,
22
- resolvePiApiKey,
23
22
  resolvePiModel,
24
23
  withProviderRetry,
25
24
  } from "../../../pi/runtime-adapter/models";
@@ -398,8 +397,6 @@ export abstract class CloudflareSubAgent<
398
397
  input,
399
398
  model,
400
399
  streamFn: withProviderRetry(models.streamSimple.bind(models)),
401
- getApiKey: () =>
402
- resolvePiApiKey(provider, provider.defaultModel),
403
400
  tools,
404
401
  signal,
405
402
  onEvent: (event) => {
@@ -12,6 +12,7 @@ import type {
12
12
  import { z } from "zod";
13
13
  import { assembleSubagentPrompt } from "../../../lib/prompt";
14
14
  import type { AgentType } from "../../../layers/orchestration/subagents/agent-types/contract";
15
+ import { structuredOutputStream } from "../../../pi/runtime-adapter/models";
15
16
 
16
17
  const RECURSIVE_TOOLS = new Set([
17
18
  "run_agent",
@@ -28,7 +29,6 @@ export interface CloudflareSubAgentRun {
28
29
  model: Model<Api>;
29
30
  streamFn: StreamFn;
30
31
  tools: AgentTool[];
31
- getApiKey?: (provider: string) => string | undefined;
32
32
  signal?: AbortSignal;
33
33
  timeoutMs?: number;
34
34
  onEvent?: (event: AgentEvent, signal: AbortSignal) => void | Promise<void>;
@@ -67,11 +67,16 @@ function toolErrorText(result: unknown): string {
67
67
  return "tool execution failed";
68
68
  }
69
69
 
70
- function taskPrompt(type: AgentType, input: unknown): string {
70
+ function taskPrompt(
71
+ input: unknown,
72
+ outputSchema?: Record<string, unknown>,
73
+ ): string {
71
74
  return [
72
75
  "Complete this bounded task.",
73
76
  `Input:\n${JSON.stringify(input)}`,
74
- `Return only one JSON object matching this schema:\n${JSON.stringify(z.toJSONSchema(type.outputSchema))}`,
77
+ outputSchema
78
+ ? `Return only one JSON object matching this schema:\n${JSON.stringify(outputSchema)}`
79
+ : "Return only one JSON object matching the configured output schema.",
75
80
  ].join("\n\n");
76
81
  }
77
82
 
@@ -90,13 +95,18 @@ export async function runCloudflareSubAgent(
90
95
  }
91
96
  }
92
97
  if (run.signal?.aborted) throw new Error("SubAgent run aborted");
98
+ const { $schema: _, ...outputSchema } = z.toJSONSchema(run.type.outputSchema);
99
+ const constrainedStream = structuredOutputStream(
100
+ run.streamFn,
101
+ run.model,
102
+ outputSchema,
103
+ );
93
104
 
94
105
  // 这里没有审批闸,也装不了:到这一步工具已经被 compilePiTools 编译成 AgentTool,
95
106
  // requiredExecutionLevel 只挂在编译前的 PiToolCandidate 上,这里读不到。「子 agent 不许挂需审批的工具」
96
107
  // 这条不变量由装配处 createCloudflareSubAgentTools 的启动期断言强制执行。
97
108
  const agent = new Agent({
98
- streamFn: run.streamFn,
99
- getApiKey: run.getApiKey,
109
+ streamFn: constrainedStream ?? run.streamFn,
100
110
  initialState: {
101
111
  model: run.model,
102
112
  systemPrompt: assembleSubagentPrompt(run.type.persona),
@@ -125,7 +135,10 @@ export async function runCloudflareSubAgent(
125
135
  }, run.timeoutMs ?? DEFAULT_TIMEOUT_MS);
126
136
 
127
137
  try {
128
- await agent.prompt(taskPrompt(run.type, input.data));
138
+ await agent.prompt(taskPrompt(
139
+ input.data,
140
+ constrainedStream ? undefined : outputSchema,
141
+ ));
129
142
  } finally {
130
143
  clearTimeout(timeout);
131
144
  run.signal?.removeEventListener("abort", abort);
package/src/db/schema.ts CHANGED
@@ -25,7 +25,8 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
25
25
  assistant_message_id TEXT NOT NULL,
26
26
  abort_reason TEXT,
27
27
  recovery_error_count INTEGER NOT NULL DEFAULT 0,
28
- recovery_reason TEXT
28
+ recovery_reason TEXT,
29
+ context_overflow_retried INTEGER NOT NULL DEFAULT 0
29
30
  )`;
30
31
  const submissionColumns = new Set(
31
32
  sql<{ name: string }>`PRAGMA table_info(pi_submissions)`
@@ -77,6 +78,10 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
77
78
  if (!submissionColumns.has("recovery_reason")) {
78
79
  sql`ALTER TABLE pi_submissions ADD COLUMN recovery_reason TEXT`;
79
80
  }
81
+ if (!submissionColumns.has("context_overflow_retried")) {
82
+ sql`ALTER TABLE pi_submissions
83
+ ADD COLUMN context_overflow_retried INTEGER NOT NULL DEFAULT 0`;
84
+ }
80
85
  sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_one_running
81
86
  ON pi_submissions(status)
82
87
  WHERE status = 'running'`;
@@ -11,7 +11,8 @@ export type SubmissionStatus =
11
11
  | "error";
12
12
  export type SubmissionRecoveryReason =
13
13
  | "no_meaningful_model_progress"
14
- | "transient_model_error";
14
+ | "transient_model_error"
15
+ | "context_overflow";
15
16
 
16
17
  const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
17
18
  "completed",
@@ -422,6 +423,17 @@ export class SubmissionRepository {
422
423
  `[0]?.recovery_error_count ?? 0;
423
424
  }
424
425
 
426
+ claimContextOverflowRecovery(id: string): boolean {
427
+ return Boolean(this.sql<{ claimed: number }>`
428
+ UPDATE pi_submissions
429
+ SET context_overflow_retried = 1
430
+ WHERE submission_id = ${id}
431
+ AND status IN ('pending', 'running')
432
+ AND context_overflow_retried = 0
433
+ RETURNING 1 AS claimed
434
+ `[0]?.claimed);
435
+ }
436
+
425
437
  /**
426
438
  * 记录一个让步执行片的累计模型回合数和续跑标识。
427
439
  *
package/src/index.ts CHANGED
@@ -144,6 +144,5 @@ export {
144
144
  export {
145
145
  createPiModels,
146
146
  modelRequestUrl,
147
- resolvePiApiKey,
148
147
  resolvePiModel,
149
148
  } from "./pi/runtime-adapter/models";
@@ -855,10 +855,6 @@ export interface RuntimeTurnEventsPort {
855
855
  readonly submissionId: string;
856
856
  readonly approvalExecutionId: string;
857
857
  }): Promise<void>;
858
- /**
859
- * 至少一次投当前 Session Activity;Host 应按 revision 幂等处理重试。
860
- * Runtime 恢复时会略过已过时的 revision,避免重放旧 working 状态。
861
- */
862
858
  onActivityChanged?(
863
859
  projection: RuntimeActivityProjection,
864
860
  ): Promise<void>;
@@ -92,14 +92,7 @@ export interface SubmissionStore<TSubmission extends SubmissionRecord> {
92
92
  countPending(): number;
93
93
  findRunning(): TSubmission | null;
94
94
  findNextPending(): TSubmission | null;
95
- /**
96
- * 列出全部等待或运行中的提交标识。
97
- *
98
- * @remarks
99
- * `stop` 没有指定请求时调用,并逐条走统一取消路径。
100
- *
101
- * 只返回标识可以避免停止入口复制完整记录或状态判断。
102
- */
95
+ listPending(): TSubmission[];
103
96
  /**
104
97
  * 持久化一条取消原因。
105
98
  *
@@ -694,5 +687,29 @@ export class SubmissionLifecycle<
694
687
  return { ok: submissionIds.length > 0 };
695
688
  }
696
689
 
690
+ /** Stops every running or queued Submission without pumping between them. */
691
+ async stopAll(reason = "Stopped"): Promise<{ ok: boolean }> {
692
+ const running = this.options.store.findRunning();
693
+ const submissions = [
694
+ ...(running ? [running] : []),
695
+ ...this.options.store.listPending(),
696
+ ];
697
+ if (submissions.length === 0) return { ok: false };
698
+
699
+ this.options.store.transaction(() => {
700
+ for (const submission of submissions) {
701
+ this.options.store.updateAbortReason(submission.submissionId, reason);
702
+ this.options.appendAbortIntent(submission, reason);
703
+ }
704
+ });
705
+ for (const submission of submissions) {
706
+ const active = this.activeBySubmission.get(submission.submissionId);
707
+ if (active) this.options.abortActive(active);
708
+ await this.finish(submission, "aborted", reason);
709
+ }
710
+ this.pump();
711
+ return { ok: true };
712
+ }
713
+
697
714
  // #endregion
698
715
  }
@@ -3,6 +3,7 @@ import {
3
3
  compact as compactPiTranscript,
4
4
  DEFAULT_COMPACTION_SETTINGS,
5
5
  estimateContextTokens,
6
+ estimateTokens,
6
7
  prepareCompaction,
7
8
  type AgentMessage,
8
9
  type CompactionEntry,
@@ -86,9 +87,9 @@ function sumUsage(responses: readonly AssistantMessage[]): Usage | undefined {
86
87
  export async function compactPiContext(input: Readonly<{
87
88
  branch: readonly SessionTreeEntry[];
88
89
  compactAfterTokens: number;
90
+ force?: boolean;
89
91
  models: Models;
90
92
  model: Model<any>;
91
- apiKey?: string;
92
93
  signal?: AbortSignal;
93
94
  }>): Promise<{
94
95
  messages: AgentMessage[];
@@ -116,8 +117,8 @@ export async function compactPiContext(input: Readonly<{
116
117
  const settings = DEFAULT_COMPACTION_SETTINGS;
117
118
  if (
118
119
  !settings.enabled ||
119
- estimateContextTokens(currentMessages).tokens <
120
- input.compactAfterTokens
120
+ (!input.force && estimateContextTokens(currentMessages).tokens <
121
+ input.compactAfterTokens)
121
122
  ) {
122
123
  return {
123
124
  messages: currentMessages,
@@ -149,10 +150,7 @@ export async function compactPiContext(input: Readonly<{
149
150
  context: Parameters<Models["completeSimple"]>[1],
150
151
  options?: Parameters<Models["completeSimple"]>[2],
151
152
  ) => {
152
- const response = await input.models.completeSimple(model, context, {
153
- ...options,
154
- ...(input.apiKey ? { apiKey: input.apiKey } : {}),
155
- });
153
+ const response = await input.models.completeSimple(model, context, options);
156
154
  responses.push(response);
157
155
  return response;
158
156
  },
@@ -199,10 +197,25 @@ export async function compactPiContext(input: Readonly<{
199
197
  ? firstResponse.stopReason
200
198
  : undefined;
201
199
  const usage = sumUsage(responses);
200
+ const messages = buildSessionContext(
201
+ [...branch, previewEntry],
202
+ ).messages;
203
+ if (
204
+ input.force &&
205
+ messages.reduce((total, message) => total + estimateTokens(message), 0) >=
206
+ currentMessages.reduce(
207
+ (total, message) => total + estimateTokens(message),
208
+ 0,
209
+ )
210
+ ) {
211
+ return {
212
+ messages: currentMessages,
213
+ didCompact: false,
214
+ degraded: true,
215
+ };
216
+ }
202
217
  return {
203
- messages: buildSessionContext(
204
- [...branch, previewEntry],
205
- ).messages,
218
+ messages,
206
219
  compactionEntry,
207
220
  ...(firstResponse && usage
208
221
  ? {
@@ -2,8 +2,11 @@ import type {
2
2
  AgentMessage,
3
3
  ThinkingLevel,
4
4
  } from "@earendil-works/pi-agent-core";
5
- import type { Api, Model } from "@earendil-works/pi-ai";
6
- import { clampReasoning } from "@earendil-works/pi-ai/api/simple-options";
5
+ import {
6
+ clampThinkingLevel,
7
+ type Api,
8
+ type Model,
9
+ } from "@earendil-works/pi-ai";
7
10
  import type {
8
11
  RuntimeMcpServer,
9
12
  RuntimeProfile,
@@ -84,15 +87,14 @@ function freezeModel(model: Model<Api>): Model<Api> {
84
87
  });
85
88
  }
86
89
 
87
- // 作用:把 Runtime 的思考强度名称转成 Pi Agent Core 接受的名称。
88
- // 调用:创建 Runtime 装配快照时对 profile 中的 `thinking` 调用。
89
- // 原因:`none` 映射成 `off`;xhigh/max 用 clampReasoning 夹到 API 真正支持的最高档,
90
- // 避免超出范围的等级在 provider 侧静默失败。
90
+ // 作用:把 Runtime 的思考强度夹到当前模型真正支持的 Pi 档位。
91
+ // 调用:创建 Runtime 装配快照时对已解析模型和 profile `thinking` 调用。
91
92
  function toPiThinkingLevel(
93
+ model: Model<Api>,
92
94
  thinking: ThinkingEffort,
93
95
  ): ThinkingLevel {
94
96
  if (thinking === "none") return "off";
95
- return clampReasoning(thinking) ?? "off";
97
+ return clampThinkingLevel(model, thinking);
96
98
  }
97
99
 
98
100
  // 作用:复制并冻结一个 Extension 配置的 manifest 和权限集合。
@@ -197,10 +199,14 @@ export function createPiRuntimeAssembly(
197
199
  Object.freeze(messages);
198
200
  Object.freeze(mcpServers);
199
201
  Object.freeze(extensions);
202
+ const model = freezeModel(
203
+ resolvePiModel(input.provider, input.profile.model),
204
+ );
200
205
 
201
206
  return Object.freeze({
202
- model: freezeModel(resolvePiModel(input.provider, input.profile.model)),
207
+ model,
203
208
  thinkingLevel: toPiThinkingLevel(
209
+ model,
204
210
  input.profile.thinking,
205
211
  ),
206
212
  systemPrompt: assembleSystemPrompt(
@@ -43,7 +43,15 @@ function userContent(
43
43
  data: inlineImage[2]!,
44
44
  });
45
45
  } else {
46
- content.push({ type: "text", text: attachmentText(part) });
46
+ content.push(
47
+ { type: "text", text: attachmentText(part) },
48
+ {
49
+ type: "file",
50
+ url: part.url,
51
+ mimeType: part.mediaType,
52
+ ...(part.filename ? { filename: part.filename } : {}),
53
+ },
54
+ );
47
55
  }
48
56
  }
49
57
  if (content.length === 0) throw new Error("User message content is required");
@@ -123,17 +123,40 @@ function toolCallAt(
123
123
  return part?.type === "toolCall" ? part : undefined;
124
124
  }
125
125
 
126
- function finishReason(reason: string): FinishReason {
127
- switch (reason) {
126
+ /** The one interpretation of a non-Tool Pi assistant terminal. */
127
+ export function classifyPiTerminalStopReason(
128
+ stopReason: string | undefined,
129
+ ): {
130
+ outcome: "succeeded" | "failed" | "aborted";
131
+ turnStatus: "completed" | "error" | "aborted";
132
+ finishReason?: FinishReason;
133
+ message?: string;
134
+ } {
135
+ switch (stopReason) {
128
136
  case "stop":
129
137
  case "length":
130
- return reason;
131
- case "toolUse":
132
- return "tool-calls";
138
+ return {
139
+ outcome: "succeeded",
140
+ turnStatus: "completed",
141
+ finishReason: stopReason,
142
+ };
143
+ case "aborted":
144
+ return { outcome: "aborted", turnStatus: "aborted" };
133
145
  case "error":
134
- return "error";
146
+ return {
147
+ outcome: "failed",
148
+ turnStatus: "error",
149
+ finishReason: "error",
150
+ };
135
151
  default:
136
- return "other";
152
+ return {
153
+ outcome: "failed",
154
+ turnStatus: "error",
155
+ finishReason: "error",
156
+ message: `SpringBrand ended the turn with an unrecognized stop reason: ${
157
+ stopReason ?? "(none)"
158
+ }`,
159
+ };
137
160
  }
138
161
  }
139
162
 
@@ -345,8 +368,15 @@ export class PiChunkEncoder {
345
368
  update.error.errorMessage ?? update.reason,
346
369
  ) ?? "SpringBrand turn failed",
347
370
  }];
348
- default:
371
+ case "start":
372
+ case "done":
349
373
  return [];
374
+ default:
375
+ throw new Error(
376
+ `Unsupported SpringBrand AssistantMessageEvent: ${
377
+ String((update as { type?: unknown }).type)
378
+ }`,
379
+ );
350
380
  }
351
381
  }
352
382
 
@@ -448,43 +478,39 @@ export class PiChunkEncoder {
448
478
  }
449
479
  this.finished = true;
450
480
  const publicError = publicAssistantError(message.errorMessage);
481
+ const terminal = classifyPiTerminalStopReason(message.stopReason);
451
482
  if (this.startedAt !== undefined) {
452
- const turnStatus = message.stopReason === "aborted"
453
- ? "aborted"
454
- : message.stopReason === "error"
455
- ? "error"
456
- : "completed";
457
483
  chunks.push({
458
484
  type: "message-metadata",
459
485
  messageMetadata: {
460
486
  createdAt: this.startedAt,
461
487
  completedAt: message.timestamp,
462
488
  turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
463
- turnStatus,
489
+ turnStatus: terminal.turnStatus,
464
490
  ...(this.turnId ? { turnId: this.turnId } : {}),
465
- ...(publicError &&
466
- (message.stopReason !== "aborted" ||
491
+ ...((publicError ?? terminal.message) &&
492
+ (terminal.outcome !== "aborted" ||
467
493
  message.errorMessage !== USER_STOP_REASON)
468
- ? { error: publicError }
494
+ ? { error: publicError ?? terminal.message }
469
495
  : {}),
470
496
  },
471
497
  });
472
498
  }
473
- if (message.stopReason === "aborted") {
499
+ if (terminal.outcome === "aborted") {
474
500
  chunks.push({ type: "abort", reason: message.errorMessage });
475
501
  return chunks;
476
502
  }
477
- if (message.stopReason === "error") {
503
+ if (terminal.outcome === "failed") {
478
504
  chunks.push({
479
505
  type: "error",
480
- errorText: publicError ?? "SpringBrand turn failed",
506
+ errorText: publicError ?? terminal.message ?? "SpringBrand turn failed",
481
507
  });
482
508
  return chunks;
483
509
  }
484
510
  chunks.push({ type: "finish-step" });
485
511
  chunks.push({
486
512
  type: "finish",
487
- finishReason: finishReason(message.stopReason),
513
+ finishReason: terminal.finishReason ?? "other",
488
514
  });
489
515
  return chunks;
490
516
  }
@@ -200,46 +200,18 @@ export interface PreparedPiRuntimeState {
200
200
 
201
201
  // #region 版本描述与准备句柄
202
202
 
203
- const IDEMPOTENT_TOOL_NAMES = new Set([
204
- "activate_skill",
205
- "browser_extract",
206
- "browser_links",
207
- "browser_markdown",
208
- "browser_scrape",
209
- "bind_resource",
210
- "delete",
211
- "edit",
212
- "execute",
213
- "find",
214
- "get_time",
215
- "grep",
216
- "list",
217
- "list_resources",
218
- "list_extensions",
219
- "list_schedules",
220
- "read",
221
- "read_skill_resource",
222
- "sandbox_process_logs",
223
- "web_search",
224
- "write",
225
- "unbind_resource",
226
- ]);
227
-
228
203
  /**
229
204
  * 判断工具在执行结果不确定时能否安全重试。
230
205
  *
231
206
  * @remarks
232
207
  * Turn 执行在记录第一次尝试前调用它,恢复流程随后使用这条持久化结论。
233
208
  *
234
- * 白名单故意保持保守,未知工具和新工具都会默认按不可重复执行处理。
209
+ * 重试语义由 Tool Candidate 自己声明;未声明的工具失败关闭为不可重复执行。
235
210
  */
236
211
  export function piToolRetryPolicy(
237
212
  candidate: PiToolCandidate,
238
213
  ): "idempotent" | "non-idempotent" {
239
- return candidate.retry ?? (IDEMPOTENT_TOOL_NAMES.has(candidate.tool.name) ||
240
- candidate.owner.startsWith("subagent:")
241
- ? "idempotent"
242
- : "non-idempotent");
214
+ return candidate.retry ?? "non-idempotent";
243
215
  }
244
216
 
245
217
  // 把工具候选整理成版本描述里要保存的稳定字段。