@springbrand/agent-runtime 0.3.0-alpha.4 → 0.3.0-alpha.6

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.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createModels,
3
+ lazyStream,
3
4
  type MutableModels,
4
5
  } from "@earendil-works/pi-ai";
5
6
  import {
@@ -23,6 +24,7 @@ import {
23
24
  type PreparePiRuntimeOptions,
24
25
  type PreparedPiRuntime,
25
26
  } from "./pinning";
27
+ import { refreshModelGuidance } from "./model-guidance";
26
28
 
27
29
  /**
28
30
  * Runtime 通过这个配置入口准备、固定并读取 Harness 的模型、提示和工具输入。
@@ -109,11 +111,17 @@ export class HarnessRuntimeConfig {
109
111
  : target.getModel(provider, modelId);
110
112
  }
111
113
  if (property === "streamSimple") {
112
- return withProviderBoundary(
114
+ const stream = withProviderBoundary(
113
115
  target.streamSimple.bind(target),
114
116
  request.modelSessionId,
115
117
  request.onGeneration,
116
118
  );
119
+ return ((model, context, options) => lazyStream(model, async () => {
120
+ options?.signal?.throwIfAborted();
121
+ const refreshed = await refreshModelGuidance(state.candidates, context);
122
+ options?.signal?.throwIfAborted();
123
+ return stream(model, refreshed, options);
124
+ })) satisfies typeof stream;
117
125
  }
118
126
  if (property === "completeSimple") {
119
127
  return target.completeSimple.bind(models);
@@ -0,0 +1,21 @@
1
+ import type { Context } from "@earendil-works/pi-ai";
2
+ import type { PiToolCandidate } from "../tools/compiler";
3
+
4
+ /** Only descriptions and guidance may vary inside a pinned Submission. */
5
+ export async function refreshModelGuidance(
6
+ candidates: readonly PiToolCandidate[],
7
+ context: Context,
8
+ ): Promise<Context> {
9
+ const guidance = new Map(await Promise.all(candidates
10
+ .filter(candidate => candidate.modelGuidance && context.tools?.some(tool => tool.name === candidate.tool.name))
11
+ .map(async candidate => [candidate.tool.name, await candidate.modelGuidance!()] as const)));
12
+ if (!guidance.size) return context;
13
+ return {
14
+ ...context,
15
+ systemPrompt: [context.systemPrompt, ...new Set([...guidance.values()].map(item => item.systemPrompt).filter(Boolean))].filter(Boolean).join("\n\n"),
16
+ tools: context.tools?.map(tool => {
17
+ const prompt = guidance.get(tool.name);
18
+ return prompt ? { ...tool, description: prompt.description } : tool;
19
+ }),
20
+ };
21
+ }
@@ -249,9 +249,20 @@ function describeTools(candidates: readonly PiToolCandidate[]) {
249
249
  description: candidate.tool.description ?? candidate.tool.label,
250
250
  parameters: candidate.tool.parameters,
251
251
  requiredExecutionLevel: candidate.requiredExecutionLevel,
252
+ ...(candidate.confirmation ? { confirmation: true } : {}),
252
253
  ...(candidate.alwaysRequiresApproval
253
254
  ? { alwaysRequiresApproval: true }
254
255
  : {}),
256
+ ...(candidate.unattendedExecution
257
+ ? {
258
+ unattendedExecution: {
259
+ capability: candidate.unattendedExecution.capability,
260
+ operation: candidate.unattendedExecution.operation,
261
+ objectConstrained:
262
+ candidate.unattendedExecution.objectForInput !== undefined,
263
+ },
264
+ }
265
+ : {}),
255
266
  ...(candidate.deferLoading ? { deferLoading: true } : {}),
256
267
  ...(candidate.source ? { source: candidate.source } : {}),
257
268
  retry: piToolRetryPolicy(candidate),
@@ -1,10 +1,12 @@
1
+ export type { RuntimeSchedulePreparation } from "../submission/contracts";
1
2
  import type { RuntimeModelInputModality } from "./profile";
2
3
  import type { SkillSource } from "agents/skills";
3
- import type { ScheduleSpec } from "../submission/contracts";
4
+ import type { ScheduleSpec, RuntimeSchedulePreparation } from "../submission/contracts";
4
5
  import type { RuntimeActivityProjection } from "../conversation/state";
5
6
  import type { ExecutionLevel } from "../lib/execution-level";
6
7
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
7
8
  import type { PiToolCandidate } from "../tools/compiler";
9
+ import type { RuntimeUnattendedExecutionGrant } from "../submission/contracts";
8
10
  import type {
9
11
  RuntimeEventConfirmation,
10
12
  RuntimeLifecycleFact,
@@ -669,16 +671,25 @@ export interface RuntimeScheduleAgentSummary {
669
671
  * 让 Runtime 通过 Host 管理当前用户的定时任务。
670
672
  *
671
673
  * @remarks
672
- * Schedule Plugin 在装配时把它交给基础工具,模型在用户要求创建、查询或取消时调用。
674
+ * Host 提供准备与提交操作;Runtime 在现有持久化审批中固定候选并等待用户确认。
673
675
  *
674
- * 任务归属和持久化留在 Host,Runtime 只传递 `ScheduleSpec` 和窄结果。
676
+ * 任务事实、候选版本、授权检查和保存幂等由 Host 持有,不存放凭据。
675
677
  */
678
+ export interface RuntimeScheduleConfirmationPort {
679
+ /** Read-only preparation; bind the full candidate and authorization to this stable intent. */
680
+ prepare(request: RuntimeScheduleUpdate & { intentId: string; id?: string }): Promise<RuntimeSchedulePreparation>;
681
+ /** Atomically check candidate/version, live authorization and quotas. Replay must return the same task. */
682
+ commit(prepared: RuntimeSchedulePreparation, context: { intentId: string; signal: AbortSignal }): Promise<{ id: string }>;
683
+ }
684
+
676
685
  export interface RuntimeSchedulePort {
686
+ /** Required for chat creation/editing. Legacy management callers retain their CRUD contract. */
687
+ confirmation?: RuntimeScheduleConfirmationPort;
677
688
  /**
678
689
  * 创建一条定时任务并返回它的 ID。
679
690
  *
680
691
  * @remarks
681
- * `schedule` 工具在把用户的时间与提示词组成 `ScheduleSpec` 后调用。
692
+ * 保留给既有宿主管理调用者;聊天 `schedule` 只经 confirmation.commit 保存。
682
693
  *
683
694
  * Host 在该边界内绑定用户与会话,Runtime 不携带业务归属 ID。
684
695
  */
@@ -1020,6 +1031,25 @@ export interface RuntimePlatformPort {
1020
1031
  requiredExecutionLevel: ExecutionLevel;
1021
1032
  signal: AbortSignal;
1022
1033
  }): Promise<void>;
1034
+ /**
1035
+ * Match a pinned scheduled-task grant against the concrete Tool operation
1036
+ * and recheck the Host-owned live account authorization.
1037
+ *
1038
+ * This callback must fail closed by throwing a user-safe, specific reason.
1039
+ * It is called before dispatch and again at the effect boundary so a grant
1040
+ * revocation cannot be hidden by an earlier decision.
1041
+ */
1042
+ authorizeUnattendedExecution?(request: {
1043
+ grant: RuntimeUnattendedExecutionGrant;
1044
+ capability: string;
1045
+ operation: string;
1046
+ object?: unknown;
1047
+ toolCallId: string;
1048
+ toolName: string;
1049
+ input: unknown;
1050
+ requiredExecutionLevel: ExecutionLevel;
1051
+ signal: AbortSignal;
1052
+ }): Promise<void>;
1023
1053
  }
1024
1054
 
1025
1055
  /**
@@ -68,6 +68,17 @@ export interface RuntimeTurnState {
68
68
  queued: RuntimeQueuedSubmission[];
69
69
  }
70
70
 
71
+ /**
72
+ * 本 Session 当前是否拒绝普通用户输入。
73
+ *
74
+ * 定时 Run 未结束时它独占输入:宿主据此禁用输入框并显示原因。
75
+ * 服务端准入用同一事实拒绝消息,所以客户端状态与后端判定不会漂移。
76
+ */
77
+ export interface RuntimeInputLock {
78
+ /** 稳定原因码,由宿主/UI 决定文案。 */
79
+ reason: string;
80
+ }
81
+
71
82
  /**
72
83
  * 保存 Kernel 在当前会话里自己维护的审批状态。
73
84
  *
@@ -85,6 +96,8 @@ export interface RuntimeState {
85
96
  activity?: RuntimeActivityProjection;
86
97
  approvals?: ApprovalReceipt[];
87
98
  turn?: RuntimeTurnState;
99
+ /** 有值时普通输入被锁定,直到锁定的 Submission 进入终态。 */
100
+ inputLock?: RuntimeInputLock;
88
101
  }
89
102
 
90
103
  type SubmissionProjection = Pick<
@@ -103,6 +116,8 @@ export function projectRuntimeState(input: {
103
116
  readonly hasPendingInteraction: (submissionId: string) => boolean;
104
117
  readonly hasPendingSteer: (submissionId: string) => boolean;
105
118
  readonly hasRunningAgentTools: boolean;
119
+ /** A non-terminal scheduled Submission owns ordinary input until it ends. */
120
+ readonly hasUnfinishedScheduled?: boolean;
106
121
  readonly previousState: RuntimeState;
107
122
  }): { state: RuntimeState; changed: boolean } {
108
123
  const approvals: ApprovalReceipt[] = [
@@ -164,12 +179,18 @@ export function projectRuntimeState(input: {
164
179
  backgroundWork: input.hasRunningAgentTools,
165
180
  revision: (previousActivity?.revision ?? 0) + 1,
166
181
  };
182
+ const inputLock: RuntimeInputLock | undefined = input.hasUnfinishedScheduled
183
+ ? { reason: "scheduled-run" }
184
+ : undefined;
167
185
  const changed = input.previousState.approvals === undefined ||
168
186
  JSON.stringify(input.previousState.approvals) !== JSON.stringify(approvals) ||
169
187
  JSON.stringify(input.previousState.turn) !== JSON.stringify(turn) ||
188
+ JSON.stringify(input.previousState.inputLock) !== JSON.stringify(inputLock) ||
170
189
  nextActivity !== previousActivity;
171
190
  return {
172
- state: changed ? { ...input.previousState, activity: nextActivity, approvals, turn } : input.previousState,
191
+ state: changed
192
+ ? { ...input.previousState, activity: nextActivity, approvals, turn, inputLock }
193
+ : input.previousState,
173
194
  changed,
174
195
  };
175
196
  }
@@ -175,12 +175,13 @@ export class SessionHarness<TContext extends object | undefined> {
175
175
  }
176
176
 
177
177
  private async operationEntries(context: Context): Promise<Entry[]> {
178
+ // Stop boundaries follow traversal order; walk back from the current tip.
178
179
  const entries = await this.lane.findEntries({
179
180
  ...(this.operationStartTip === null ? {} : { stopAtId: this.operationStartTip }),
180
181
  type: "message",
181
- order: "oldestFirst",
182
+ order: "newestFirst",
182
183
  }, context);
183
- return entries.filter(({ id }) => id !== this.operationStartTip);
184
+ return entries.filter(({ id }) => id !== this.operationStartTip).reverse();
184
185
  }
185
186
 
186
187
  private async restoreToolGovernance(context: Context): Promise<void> {
package/src/lib/prompt.ts CHANGED
@@ -70,7 +70,10 @@ export const TOOLS =
70
70
  "prioritize verification, saving durable results, and the final response. " +
71
71
  "When related Tool calls can run independently, all are available inside execute, and execute is present, run them inside that execute rather than as parallel top-level calls; " +
72
72
  "only independent top-level-only Direct Tools should be issued together in one turn. When you reference " +
73
- "code, cite it as file_path:line_number.";
73
+ "code, cite it as file_path:line_number. " +
74
+ "When schedule tools are available, prepare a standalone Requirement (at most 1000 Unicode characters), a name (at most 50), a time rule and an explicit timezone. Ask for missing information. " +
75
+ "Use local once/daily/weekly rules for new scheduled tasks. The Host prepares the authorization scope and the user must confirm before saving; never claim a task was created before the Tool succeeds. " +
76
+ "One source Chat may create multiple tasks. Before editing, list tasks and use the exact Task ID; ask when the target is ambiguous. Never attach source history or hidden context to the Requirement.";
74
77
 
75
78
  // A real browser is the only way to observe what a page actually does, and the
76
79
  // shared failure mode is a browser tool that returns nothing and looks clean.
@@ -12,7 +12,8 @@ import {
12
12
  SubmissionQueueFullError,
13
13
  type SubmissionInput,
14
14
  } from "./lifecycle";
15
- import { isTerminalSubmissionStatus, submissionAbortReason, type StoredSubmission, type SubmissionRepository } from "./repository";
15
+ import { isTerminalSubmissionStatus, submissionAbortReason, type StoredSubmission, type SubmissionOrigin, type SubmissionRepository } from "./repository";
16
+ import type { RuntimeUnattendedExecutionGrant } from "./contracts";
16
17
 
17
18
  // Pending records retain identity indefinitely; only recent attempts reserve capacity.
18
19
  const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
@@ -23,6 +24,10 @@ export interface SubmitMessageOptions {
23
24
  userMessageId?: string;
24
25
  userMessage?: UIMessage & { role: "user" };
25
26
  regenerate?: boolean;
27
+ /** `scheduled` marks a Run injection that locks ordinary input until it ends. */
28
+ origin?: SubmissionOrigin;
29
+ /** Trusted Host grant; accepted only for a scheduled Submission. */
30
+ unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant;
26
31
  /** Internal transfer of a durable accepted steer; never a public message option. */
27
32
  steerHandoff?: { submissionId: string; steerId: string };
28
33
  }
@@ -182,6 +187,8 @@ export class SubmissionAdmission {
182
187
  ? options.userMessageId ?? null
183
188
  : null,
184
189
  runId,
190
+ origin: options.origin,
191
+ unattendedExecutionGrant: options.unattendedExecutionGrant,
185
192
  });
186
193
  const admitted = this.options.submissions.find(runId)!;
187
194
  this.options.onAdmitted(admitted, admission?.accepted === true);
@@ -13,6 +13,8 @@ export const APPROVAL_DECISIONS = [
13
13
  "allow_level",
14
14
  ] as const satisfies readonly ApprovalDecision["decision"][];
15
15
 
16
+ export type { RuntimeUnattendedExecutionGrant } from "./unattended";
17
+
16
18
  /**
17
19
  * 记录一次待审批 Tool 执行的身份、摘要和原始输入。
18
20
  *
@@ -58,12 +60,35 @@ export type ScheduleJob =
58
60
  *
59
61
  * 触发条件与工作放在同一份规格里,使更新时可以原地替换两者,不用改变任务标识。
60
62
  */
63
+ /**
64
+ * 一条按任务时区解释的当地时间规则。
65
+ *
66
+ * @remarks
67
+ * `once` 在单个本地日历日触发;`daily` 每个本地日触发;`weekly` 在选中的每个本地星期触发
68
+ * (0 = 周日 … 6 = 周六)。所有星期共用同一个本地钟点。
69
+ *
70
+ * 规则只描述当地墙上时间;缺失/重复钟点、起止日期和夏令时语义由
71
+ * `adapter/cloudflare/scheduling` 的公共计算边界处理。
72
+ */
73
+ export type LocalScheduleRule =
74
+ | { kind: "once"; date: string; time: string }
75
+ | { kind: "daily"; time: string }
76
+ | { kind: "weekly"; weekdays: number[]; time: string };
77
+
61
78
  export interface ScheduleSpec {
62
79
  trigger:
63
80
  | { kind: "once-at"; at: string }
64
81
  | { kind: "once-in"; seconds: number }
65
82
  | { kind: "cron"; cron: string }
66
- | { kind: "interval"; seconds: number };
83
+ | { kind: "interval"; seconds: number }
84
+ | {
85
+ kind: "local";
86
+ rule: LocalScheduleRule;
87
+ /** 含当天的最早生效本地日期 `YYYY-MM-DD`。 */
88
+ startDate?: string;
89
+ /** 含当天的最后生效本地日期 `YYYY-MM-DD`。 */
90
+ endDate?: string;
91
+ };
67
92
  job: ScheduleJob;
68
93
  label?: string;
69
94
  /**
@@ -115,7 +140,9 @@ export type MessageDelivery = "steer" | "enqueue";
115
140
  export type MessageDispatchErrorCode =
116
141
  | "queue_full"
117
142
  | "not_steerable"
118
- | "invalid_message";
143
+ | "invalid_message"
144
+ /** A scheduled Run owns this Session's input until it reaches a terminal state. */
145
+ | "input_locked";
119
146
 
120
147
  export type MessageDispatchReceipt =
121
148
  | {
@@ -139,3 +166,16 @@ export type MessageDispatchReceipt =
139
166
  code: MessageDispatchErrorCode;
140
167
  message: string;
141
168
  };
169
+
170
+ /** Complete, credential-free task version prepared by a trusted Host for human confirmation. */
171
+ export interface RuntimeSchedulePreparation {
172
+ candidateId: string;
173
+ candidateVersion: string;
174
+ id?: string;
175
+ prompt: string;
176
+ label: string;
177
+ trigger: ScheduleSpec["trigger"];
178
+ timezone: string;
179
+ nextRunAt?: number;
180
+ authorization: { version: string; missing: string[]; scope: string };
181
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SqlTaggedTemplate } from "agents/chat";
2
2
  import type { LaneSnapshot } from "@earendil-works/pi-agent-core";
3
+ import type { RuntimeUnattendedExecutionGrant } from "./unattended";
3
4
 
4
5
  // Submission 的统一定义见 ./index.ts。
5
6
  // #region 状态与行类型
@@ -11,6 +12,14 @@ export type SubmissionStatus =
11
12
  | "skipped"
12
13
  | "error";
13
14
 
15
+ /**
16
+ * 谁发起了这条 Submission。
17
+ *
18
+ * `scheduled` 是定时 Run 的注入:它在本 Session 未结束期间独占输入,
19
+ * 普通 `user` Submission 必须被拒绝,而不是排队等它结束。
20
+ */
21
+ export type SubmissionOrigin = "user" | "scheduled";
22
+
14
23
  const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
15
24
  "completed",
16
25
  "aborted",
@@ -46,6 +55,9 @@ export interface StoredSubmission {
46
55
  userMessageId: string | null;
47
56
  regenerateMessageId: string | null;
48
57
  runId: string | null;
58
+ origin: SubmissionOrigin;
59
+ unattendedExecutionGrant: RuntimeUnattendedExecutionGrant | null;
60
+ unattendedFailure: string | null;
49
61
  retryable?: boolean;
50
62
  }
51
63
 
@@ -69,6 +81,8 @@ export interface NewSubmission {
69
81
  userMessageId?: string | null;
70
82
  regenerateMessageId?: string | null;
71
83
  runId?: string | null;
84
+ origin?: SubmissionOrigin;
85
+ unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant;
72
86
  }
73
87
 
74
88
  type SubmissionRow = {
@@ -90,7 +104,10 @@ type SubmissionRow = {
90
104
  user_message_id: string | null;
91
105
  regenerate_message_id: string | null;
92
106
  run_id: string | null;
107
+ origin: string | null;
93
108
  admission_retryable: number | null;
109
+ unattended_execution_grant_json: string | null;
110
+ unattended_failure: string | null;
94
111
  };
95
112
 
96
113
  // #endregion
@@ -118,6 +135,11 @@ function mapRow(row: SubmissionRow): StoredSubmission {
118
135
  userMessageId: row.user_message_id,
119
136
  regenerateMessageId: row.regenerate_message_id,
120
137
  runId: row.run_id,
138
+ origin: row.origin === "scheduled" ? "scheduled" : "user",
139
+ unattendedExecutionGrant: row.unattended_execution_grant_json
140
+ ? JSON.parse(row.unattended_execution_grant_json) as RuntimeUnattendedExecutionGrant
141
+ : null,
142
+ unattendedFailure: row.unattended_failure,
121
143
  retryable: row.admission_retryable === null
122
144
  ? undefined
123
145
  : row.admission_retryable === 1,
@@ -150,7 +172,10 @@ export class SubmissionRepository {
150
172
  queued_input_json, queued_ui_message_json, user_message_id,
151
173
  regenerate_message_id,
152
174
  run_id,
153
- admission_retryable
175
+ origin,
176
+ admission_retryable,
177
+ unattended_execution_grant_json,
178
+ unattended_failure
154
179
  FROM pi_submissions
155
180
  WHERE submission_id = ${id}
156
181
  `[0];
@@ -251,7 +276,7 @@ export class SubmissionRepository {
251
276
  created_at, assembly_revision, assembly_descriptor, runtime_key,
252
277
  assistant_message_id, queued_input_json, queued_ui_message_json,
253
278
  user_message_id, regenerate_message_id,
254
- run_id
279
+ run_id, origin, unattended_execution_grant_json
255
280
  ) VALUES (
256
281
  ${s.submissionId},
257
282
  ${s.requestId},
@@ -267,11 +292,30 @@ export class SubmissionRepository {
267
292
  ${s.queuedUiMessageJson ?? null},
268
293
  ${s.userMessageId ?? null},
269
294
  ${s.regenerateMessageId ?? null},
270
- ${s.runId ?? s.submissionId}
295
+ ${s.runId ?? s.submissionId},
296
+ ${s.origin ?? "user"},
297
+ ${s.unattendedExecutionGrant
298
+ ? JSON.stringify(s.unattendedExecutionGrant)
299
+ : null}
271
300
  )
272
301
  `;
273
302
  }
274
303
 
304
+ /**
305
+ * 是否存在一条未结束的定时 Submission。
306
+ *
307
+ * 定时 Run 未结束时它独占本 Session 的输入:普通消息在准入前被拒绝,
308
+ * 不排队等待。Run 进入终态后查询自然为空,普通追问恢复。
309
+ */
310
+ hasUnfinishedScheduled(): boolean {
311
+ return (
312
+ this.sql<{ count: number }>`
313
+ SELECT COUNT(*) AS count FROM pi_submissions
314
+ WHERE status IN ('pending', 'running') AND origin = 'scheduled'
315
+ `[0]?.count ?? 0
316
+ ) > 0;
317
+ }
318
+
275
319
  insertRejected(s: {
276
320
  submissionId: string;
277
321
  requestId: string;
@@ -310,6 +354,15 @@ export class SubmissionRepository {
310
354
  return updated.length === 1;
311
355
  }
312
356
 
357
+ recordUnattendedFailure(id: string, reason: string): void {
358
+ this.sql`
359
+ UPDATE pi_submissions
360
+ SET unattended_failure = COALESCE(unattended_failure, ${reason})
361
+ WHERE submission_id = ${id}
362
+ AND status IN ('pending', 'running')
363
+ `;
364
+ }
365
+
313
366
  // 只在当前状态属于 from 时把 Submission 切换到 to,否则返回 false。
314
367
  // SubmissionLifecycle 开始 Turn 时调用,调用方用返回值识别过期或重复的状态切换。
315
368
  // Cloudflare SQLite 查询是同步的,两条语句之间不会让其他事件交错;UPDATE 再次带旧状态条件,不能去掉这层幂等保护。
@@ -0,0 +1,40 @@
1
+ /** Host-confirmed authority pinned to one scheduled task Requirement version.
2
+ * Credentials never belong in scope; only trusted Host ports consume it.
3
+ */
4
+ export interface RuntimeUnattendedExecutionGrant {
5
+ readonly taskId: string;
6
+ readonly requirementVersion: string;
7
+ readonly authorizationVersion: string;
8
+ readonly scope: Readonly<Record<string, unknown>>;
9
+ }
10
+
11
+ export function pinnedUnattendedExecutionGrant(
12
+ value: RuntimeUnattendedExecutionGrant,
13
+ ): RuntimeUnattendedExecutionGrant {
14
+ for (const [name, version] of [
15
+ ["task ID", value.taskId],
16
+ ["Requirement", value.requirementVersion],
17
+ ["authorization", value.authorizationVersion],
18
+ ] as const) {
19
+ if (typeof version !== "string" || !version.trim()) {
20
+ throw new Error(`Unattended execution ${name} is required`);
21
+ }
22
+ }
23
+ if (!value.scope || typeof value.scope !== "object" || Array.isArray(value.scope)) {
24
+ throw new Error("Unattended execution scope must be an object");
25
+ }
26
+ try {
27
+ const scope = JSON.parse(JSON.stringify(value.scope)) as unknown;
28
+ if (!scope || typeof scope !== "object" || Array.isArray(scope)) {
29
+ throw new Error("scope changed shape during serialization");
30
+ }
31
+ return Object.freeze({
32
+ taskId: value.taskId,
33
+ requirementVersion: value.requirementVersion,
34
+ authorizationVersion: value.authorizationVersion,
35
+ scope: scope as Readonly<Record<string, unknown>>,
36
+ });
37
+ } catch (error) {
38
+ throw new Error("Unattended execution scope must be JSON-serializable", { cause: error });
39
+ }
40
+ }
@@ -16,6 +16,7 @@ export interface StoredApproval {
16
16
  executionLevel: ExecutionLevel;
17
17
  requiredExecutionLevel: ExecutionLevel;
18
18
  inputJson: string;
19
+ confirmationJson?: string | null;
19
20
  status: ApprovalStatus;
20
21
  createdAt: number;
21
22
  decidedAt: number | null;
@@ -33,6 +34,7 @@ export interface NewApproval {
33
34
  executionLevel: ExecutionLevel;
34
35
  requiredExecutionLevel: ExecutionLevel;
35
36
  inputJson: string;
37
+ confirmationJson?: string | null;
36
38
  status: ApprovalStatus;
37
39
  createdAt: number;
38
40
  }
@@ -46,6 +48,7 @@ export interface PendingApprovalSummary {
46
48
  executionLevel: ExecutionLevel;
47
49
  requiredExecutionLevel: ExecutionLevel;
48
50
  inputJson: string;
51
+ confirmationJson?: string | null;
49
52
  requestId: string;
50
53
  }
51
54
 
@@ -66,6 +69,7 @@ type ApprovalRow = {
66
69
  execution_level: string;
67
70
  required_level: string;
68
71
  input_json: string;
72
+ confirmation_json: string | null;
69
73
  status: string;
70
74
  created_at: number;
71
75
  decided_at: number | null;
@@ -89,6 +93,7 @@ function mapRow(row: ApprovalRow): StoredApproval {
89
93
  executionLevel: row.execution_level as ExecutionLevel,
90
94
  requiredExecutionLevel: row.required_level as ExecutionLevel,
91
95
  inputJson: row.input_json,
96
+ confirmationJson: row.confirmation_json,
92
97
  status: row.status as ApprovalStatus,
93
98
  createdAt: row.created_at,
94
99
  decidedAt: row.decided_at,
@@ -111,7 +116,7 @@ export class ApprovalRepository {
111
116
  const row = this.sql<ApprovalRow>`
112
117
  SELECT execution_id, submission_id, request_id, source,
113
118
  tool_call_id, tool_name, summary,
114
- execution_level, required_level, input_json,
119
+ execution_level, required_level, input_json, confirmation_json,
115
120
  status, created_at, decided_at, reason
116
121
  FROM pi_approvals
117
122
  WHERE execution_id = ${executionId}
@@ -128,7 +133,7 @@ export class ApprovalRepository {
128
133
  INSERT INTO pi_approvals (
129
134
  execution_id, submission_id, request_id, source,
130
135
  tool_call_id, tool_name, summary,
131
- execution_level, required_level, input_json,
136
+ execution_level, required_level, input_json, confirmation_json,
132
137
  status, created_at
133
138
  ) VALUES (
134
139
  ${approval.executionId},
@@ -141,6 +146,7 @@ export class ApprovalRepository {
141
146
  ${approval.executionLevel},
142
147
  ${approval.requiredExecutionLevel},
143
148
  ${approval.inputJson},
149
+ ${approval.confirmationJson ?? null},
144
150
  ${approval.status},
145
151
  ${approval.createdAt}
146
152
  )
@@ -192,12 +198,13 @@ export class ApprovalRepository {
192
198
  execution_level: string;
193
199
  required_level: string;
194
200
  input_json: string;
201
+ confirmation_json: string | null;
195
202
  request_id: string;
196
203
  }>`
197
204
  SELECT approval.execution_id, approval.tool_call_id, approval.source,
198
205
  approval.tool_name, approval.summary,
199
206
  approval.execution_level, approval.required_level,
200
- approval.input_json, approval.request_id
207
+ approval.input_json, approval.confirmation_json, approval.request_id
201
208
  FROM pi_approvals approval
202
209
  JOIN pi_submissions submission
203
210
  ON submission.submission_id = approval.submission_id
@@ -212,7 +219,7 @@ export class ApprovalRepository {
212
219
  summary: row.summary,
213
220
  executionLevel: row.execution_level as ExecutionLevel,
214
221
  requiredExecutionLevel: row.required_level as ExecutionLevel,
215
- inputJson: row.input_json,
222
+ inputJson: row.confirmation_json ?? row.input_json,
216
223
  requestId: row.request_id,
217
224
  }));
218
225
  }
package/src/tools/base.ts CHANGED
@@ -452,6 +452,10 @@ export function basePiToolCandidates(
452
452
  return result({ noted: true, count: input.items.length });
453
453
  },
454
454
  }),
455
+ unattendedExecution: {
456
+ capability: "runtime-ui",
457
+ operation: "suggest-followups",
458
+ },
455
459
  exposureMode: "direct",
456
460
  },
457
461
  {
@@ -470,6 +474,10 @@ export function basePiToolCandidates(
470
474
  });
471
475
  },
472
476
  }),
477
+ unattendedExecution: {
478
+ capability: "runtime-ui",
479
+ operation: "update",
480
+ },
473
481
  exposureMode: "direct",
474
482
  },
475
483
  ...(webSearch ? [webSearchPiToolCandidate(webSearch)] : []),