@springbrand/agent-runtime 0.3.0-alpha.3 → 0.3.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
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.
@@ -43,6 +43,13 @@ const RETIRED_SCHEDULE_CALLBACKS = [
43
43
  "_chatRecoveryRetry",
44
44
  ] as const;
45
45
 
46
+ const LEGACY_TERMINAL_SUBMISSION_STATUSES = new Set([
47
+ "completed",
48
+ "failed",
49
+ "aborted",
50
+ "skipped",
51
+ ]);
52
+
46
53
  type LegacyRecord = Record<string, unknown> & {
47
54
  id: string;
48
55
  parentId: string | null;
@@ -790,6 +797,9 @@ export function migrateLegacyPiSession(
790
797
  const knownSubmissions = new Set(
791
798
  submissions.map((row) => row.submission_id),
792
799
  );
800
+ const submissionStatuses = new Map(
801
+ submissions.map((row) => [row.submission_id, row.status]),
802
+ );
793
803
  for (const submission of submissions) {
794
804
  if (submission.status !== "pending" || !submission.queued_input_json)
795
805
  continue;
@@ -838,8 +848,12 @@ export function migrateLegacyPiSession(
838
848
  if (!knownSubmissions.has(row.submission_id))
839
849
  throw new Error("Dangling recovery submission");
840
850
  const record = decodePiToolRecoveryMilestone(row.body);
841
- if (!record || record.turnId !== row.submission_id)
851
+ if (!record || record.turnId !== row.submission_id) {
852
+ if (LEGACY_TERMINAL_SUBMISSION_STATUSES.has(
853
+ submissionStatuses.get(row.submission_id) ?? "",
854
+ )) continue;
842
855
  throw new Error("Invalid legacy recovery milestone");
856
+ }
843
857
  const bodies = bySubmission.get(row.submission_id) ?? [];
844
858
  bodies.push(row.body);
845
859
  bySubmission.set(row.submission_id, bodies);
@@ -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)] : []),
@@ -17,6 +17,7 @@ import {
17
17
  import {
18
18
  type ExecutionLevel,
19
19
  } from "../lib/execution-level";
20
+ import { UnattendedExecutionError } from "./unattended-error";
20
21
 
21
22
  // 本文件沿用 `../../index.ts` 入口定义的 Tool Candidate 和 Tool Settlement 术语。
22
23
 
@@ -42,10 +43,26 @@ export interface PiToolInteractionSpec {
42
43
  ) => Omit<AgentToolResult<unknown>, "terminate">;
43
44
  }
44
45
 
46
+ /** Trusted Tool metadata used to evaluate a scheduled task grant. */
47
+ export interface PiToolUnattendedExecutionSpec {
48
+ readonly capability: string;
49
+ readonly operation: string;
50
+ readonly objectForInput?: (
51
+ input: unknown,
52
+ ) => unknown | Promise<unknown>;
53
+ }
54
+
45
55
  /** 描述一个尚未进入最终 Tool Surface 和结算包装的 Pi 工具。 */
46
56
  export type ToolExposureMode = "direct" | "codemode" | "both";
47
57
 
58
+ /** Host-prepared confirmation travels through the existing durable approval. */
59
+ export interface PiToolConfirmationSpec {
60
+ prepare(input: unknown, context: { intentId: string }): Promise<unknown>;
61
+ execute(prepared: unknown, context: { intentId: string; signal: AbortSignal }): Promise<AgentToolResult<unknown>>;
62
+ }
63
+
48
64
  export interface PiToolCandidate {
65
+ readonly confirmation?: PiToolConfirmationSpec;
49
66
  readonly owner: string;
50
67
  readonly tool: AgentTool<any, any>;
51
68
  /** @internal Send the complete schema but hide it until provider Tool Search finds it. */
@@ -66,6 +83,8 @@ export interface PiToolCandidate {
66
83
  * 那是失效不是降级。`deny` 仍然优先,被 deny 的工具根本不会注入。
67
84
  */
68
85
  readonly alwaysRequiresApproval?: boolean;
86
+ /** Declares the concrete grant identity derived from actual Tool input. */
87
+ readonly unattendedExecution?: PiToolUnattendedExecutionSpec;
69
88
  /** Recovery may replay this Tool Call only when the candidate guarantees convergence. Omitted means non-idempotent. */
70
89
  readonly retry?: "idempotent" | "non-idempotent";
71
90
  readonly summary?: string;
@@ -77,6 +96,7 @@ export interface PiToolCandidate {
77
96
 
78
97
  export interface CompileHarnessToolsOptions {
79
98
  readonly workspace?: SpillWorkspace;
99
+ readonly resolveConfirmation?: (candidate: PiToolCandidate, toolCallId: string, args: unknown) => { prepared: unknown; intentId: string };
80
100
  /** Recheck live authorization at the effect boundary, including safe replay. */
81
101
  readonly authorize?: (
82
102
  candidate: PiToolCandidate,
@@ -264,27 +284,28 @@ export function compileHarnessTools(
264
284
  context,
265
285
  ) {
266
286
  const signal = context.abortSignal ?? new AbortController().signal;
267
- await options.authorize?.(candidate, args, {
268
- toolCallId,
269
- signal,
270
- });
271
- const replay = await options.resolveReplay?.(
272
- candidate,
273
- toolCallId,
274
- args,
275
- );
276
- if (replay) return durableResult(replay, candidate, options.workspace);
277
287
  let result: AgentToolResult<unknown>;
278
288
  try {
289
+ await options.authorize?.(candidate, args, {
290
+ toolCallId,
291
+ signal,
292
+ });
293
+ const replay = await options.resolveReplay?.(
294
+ candidate,
295
+ toolCallId,
296
+ args,
297
+ );
298
+ if (replay) return durableResult(replay, candidate, options.workspace);
279
299
  result = await untilAborted(
280
300
  signal,
281
301
  async () => durableResult(
282
- await source.execute(
283
- toolCallId,
284
- args,
285
- signal,
286
- (update) => onUpdate(update),
287
- ),
302
+ await (candidate.confirmation
303
+ ? (() => {
304
+ const confirmed = options.resolveConfirmation?.(candidate, toolCallId, args);
305
+ if (!confirmed) throw new Error("User confirmation is required");
306
+ return candidate.confirmation.execute(confirmed.prepared, { intentId: confirmed.intentId, signal });
307
+ })()
308
+ : source.execute(toolCallId, args, signal, (update) => onUpdate(update))),
288
309
  candidate,
289
310
  options.workspace,
290
311
  ),
@@ -305,6 +326,9 @@ export function compileHarnessTools(
305
326
  summary: candidate.summary,
306
327
  source: candidate.source,
307
328
  });
329
+ if (cause instanceof UnattendedExecutionError) {
330
+ return { ...failed, terminate: true };
331
+ }
308
332
  throw cause instanceof Error && cause.message === message
309
333
  ? cause
310
334
  : new Error(message, { cause });