@springbrand/agent-runtime 0.3.0-alpha.4 → 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.
- package/package.json +1 -1
- package/src/adapter/cloudflare/agent.ts +73 -9
- package/src/adapter/cloudflare/define-agent.ts +4 -1
- package/src/adapter/cloudflare/index.ts +18 -0
- package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
- package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
- package/src/adapter/cloudflare/schema.ts +12 -0
- package/src/config/assembler.ts +12 -0
- package/src/config/pinning.ts +11 -0
- package/src/config/ports.ts +34 -4
- package/src/conversation/state.ts +22 -1
- package/src/lib/prompt.ts +4 -1
- package/src/submission/admission.ts +8 -1
- package/src/submission/contracts.ts +42 -2
- package/src/submission/repository.ts +56 -3
- package/src/submission/unattended.ts +40 -0
- package/src/tools/approval/repository.ts +11 -4
- package/src/tools/base.ts +8 -0
- package/src/tools/compiler.ts +40 -16
- package/src/tools/decision-gate.ts +164 -29
- package/src/tools/declared.ts +4 -0
- package/src/tools/schedule.ts +66 -34
- package/src/tools/unattended-error.ts +4 -0
package/src/config/ports.ts
CHANGED
|
@@ -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
|
-
*
|
|
674
|
+
* Host 提供准备与提交操作;Runtime 在现有持久化审批中固定候选并等待用户确认。
|
|
673
675
|
*
|
|
674
|
-
*
|
|
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`
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
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)] : []),
|
package/src/tools/compiler.ts
CHANGED
|
@@ -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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 });
|