@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.
- 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/session/cloudflare/migration.ts +15 -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
|
@@ -11,11 +11,15 @@ import type { PiToolCandidate } from "./compiler";
|
|
|
11
11
|
import type { ApprovalRepository } from "./approval";
|
|
12
12
|
import type { ToolInteractionRepository } from "./interaction";
|
|
13
13
|
import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
|
|
14
|
+
import type { RuntimeUnattendedExecutionGrant } from "../submission/contracts";
|
|
15
|
+
import { UnattendedExecutionError } from "./unattended-error";
|
|
14
16
|
|
|
15
17
|
export interface HarnessDecisionSubmission {
|
|
16
18
|
readonly submissionId: string;
|
|
17
19
|
readonly requestId: string;
|
|
18
20
|
readonly executionLevel: ExecutionLevel;
|
|
21
|
+
readonly origin?: "user" | "scheduled";
|
|
22
|
+
readonly unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant | null;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
export interface HarnessDecisionGateOptions {
|
|
@@ -37,6 +41,19 @@ export interface HarnessDecisionGateOptions {
|
|
|
37
41
|
readonly requiredExecutionLevel: ExecutionLevel;
|
|
38
42
|
readonly signal: AbortSignal;
|
|
39
43
|
}) => void | Promise<void>;
|
|
44
|
+
readonly authorizeUnattendedExecution?: (input: {
|
|
45
|
+
grant: RuntimeUnattendedExecutionGrant;
|
|
46
|
+
capability: string;
|
|
47
|
+
operation: string;
|
|
48
|
+
object?: unknown;
|
|
49
|
+
toolCallId: string;
|
|
50
|
+
toolName: string;
|
|
51
|
+
input: unknown;
|
|
52
|
+
requiredExecutionLevel: ExecutionLevel;
|
|
53
|
+
signal: AbortSignal;
|
|
54
|
+
}) => void | Promise<void>;
|
|
55
|
+
readonly onUnattendedFailure?: (reason: string) => void | Promise<void>;
|
|
56
|
+
readonly unattendedFailure?: () => string | null | undefined;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
function waitForRelease(signal: AbortSignal): Promise<never> {
|
|
@@ -81,33 +98,43 @@ export class HarnessDecisionGate {
|
|
|
81
98
|
readonly beforeTool: HookHandler<"before_tool"> = async (event, context) => {
|
|
82
99
|
const candidate = this.candidates.get(event.toolName);
|
|
83
100
|
if (!candidate) return undefined;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
throw
|
|
89
|
-
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
const executionId = `${this.options.submission.submissionId}:${event.toolCallId}`;
|
|
93
|
-
const existing = this.options.approvals.find(executionId);
|
|
94
|
-
if (existing?.status === "rejected") {
|
|
95
|
-
return { block: { reason: existing.reason ?? "Tool execution denied by user" } };
|
|
101
|
+
let requiredExecutionLevel: ExecutionLevel;
|
|
102
|
+
try {
|
|
103
|
+
requiredExecutionLevel = await this.resolveExecutionLevel(candidate, event.args);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!(error instanceof UnattendedExecutionError)) throw error;
|
|
106
|
+
return { block: { reason: error.message, terminate: true } };
|
|
96
107
|
}
|
|
97
108
|
const needsApproval = candidate.alwaysRequiresApproval ||
|
|
98
109
|
requiresExecutionApproval(
|
|
99
110
|
this.options.submission.executionLevel,
|
|
100
111
|
requiredExecutionLevel,
|
|
101
112
|
);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
const executionId = `${this.options.submission.submissionId}:${event.toolCallId}`;
|
|
114
|
+
let unattendedAuthorized = false;
|
|
115
|
+
try {
|
|
116
|
+
unattendedAuthorized = await this.authorizeUnattended(candidate, event.args, {
|
|
117
|
+
toolCallId: event.toolCallId,
|
|
118
|
+
signal: operationSignal(context.abortSignal),
|
|
119
|
+
}, requiredExecutionLevel);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!(error instanceof UnattendedExecutionError)) throw error;
|
|
122
|
+
return { block: { reason: error.message, terminate: true } };
|
|
123
|
+
}
|
|
124
|
+
const existing = this.options.approvals.find(executionId);
|
|
125
|
+
if (existing && (existing.requestId !== this.options.submission.requestId ||
|
|
126
|
+
existing.toolName !== event.toolName || existing.inputJson !== stableInput(event.args))) {
|
|
127
|
+
throw new Error(`Conflicting durable Tool approval: ${event.toolCallId}`);
|
|
128
|
+
}
|
|
129
|
+
if (existing?.status === "rejected") {
|
|
130
|
+
return { block: { reason: existing.reason ?? "Tool execution denied by user" } };
|
|
131
|
+
}
|
|
132
|
+
if (needsApproval && !unattendedAuthorized && existing?.status !== "approved") {
|
|
110
133
|
if (!existing) {
|
|
134
|
+
const prepared = candidate.confirmation
|
|
135
|
+
? await candidate.confirmation.prepare(event.args, { intentId: executionId })
|
|
136
|
+
: undefined;
|
|
137
|
+
operationSignal(context.abortSignal).throwIfAborted();
|
|
111
138
|
const createdAt = Date.now();
|
|
112
139
|
this.options.transaction(() => this.options.approvals.insert({
|
|
113
140
|
executionId,
|
|
@@ -120,6 +147,7 @@ export class HarnessDecisionGate {
|
|
|
120
147
|
executionLevel: this.options.submission.executionLevel,
|
|
121
148
|
requiredExecutionLevel,
|
|
122
149
|
inputJson: stableInput(event.args),
|
|
150
|
+
confirmationJson: prepared === undefined ? null : JSON.stringify(prepared),
|
|
123
151
|
status: "pending",
|
|
124
152
|
createdAt,
|
|
125
153
|
}));
|
|
@@ -181,16 +209,123 @@ export class HarnessDecisionGate {
|
|
|
181
209
|
args: unknown,
|
|
182
210
|
invocation: { readonly toolCallId: string; readonly signal: AbortSignal },
|
|
183
211
|
): Promise<void> {
|
|
184
|
-
const requiredExecutionLevel = candidate
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
toolName: candidate.tool.name,
|
|
190
|
-
input: args,
|
|
212
|
+
const requiredExecutionLevel = await this.resolveExecutionLevel(candidate, args);
|
|
213
|
+
await this.authorizeUnattended(
|
|
214
|
+
candidate,
|
|
215
|
+
args,
|
|
216
|
+
invocation,
|
|
191
217
|
requiredExecutionLevel,
|
|
192
|
-
|
|
193
|
-
|
|
218
|
+
);
|
|
219
|
+
try {
|
|
220
|
+
await this.options.gateTool?.({
|
|
221
|
+
toolCallId: invocation.toolCallId,
|
|
222
|
+
toolName: candidate.tool.name,
|
|
223
|
+
input: args,
|
|
224
|
+
requiredExecutionLevel,
|
|
225
|
+
signal: invocation.signal,
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (this.options.submission.origin !== "scheduled") throw error;
|
|
229
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
230
|
+
await this.denyUnattended(
|
|
231
|
+
`Unattended execution denied for ${candidate.tool.name}: ${reason}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
this.assertUnattendedActive();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private assertUnattendedActive(): void {
|
|
238
|
+
if (this.options.submission.origin !== "scheduled") return;
|
|
239
|
+
const failure = this.options.unattendedFailure?.();
|
|
240
|
+
if (failure) throw new UnattendedExecutionError(failure);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async resolveExecutionLevel(candidate: PiToolCandidate, input: unknown): Promise<ExecutionLevel> {
|
|
244
|
+
this.assertUnattendedActive();
|
|
245
|
+
try {
|
|
246
|
+
const level = candidate.requiredExecutionLevelForInput
|
|
247
|
+
? await candidate.requiredExecutionLevelForInput(input)
|
|
248
|
+
: candidate.requiredExecutionLevel;
|
|
249
|
+
if (!EXECUTION_LEVELS.includes(level)) {
|
|
250
|
+
throw new Error(`SpringBrand Tool "${candidate.tool.name}" resolved an invalid execution level`);
|
|
251
|
+
}
|
|
252
|
+
return level;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (this.options.submission.origin !== "scheduled") throw error;
|
|
255
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
256
|
+
return this.denyUnattended(`Unattended execution risk check failed for ${candidate.tool.name}: ${reason}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async authorizeUnattended(
|
|
261
|
+
candidate: PiToolCandidate,
|
|
262
|
+
input: unknown,
|
|
263
|
+
invocation: { readonly toolCallId: string; readonly signal: AbortSignal },
|
|
264
|
+
requiredExecutionLevel: ExecutionLevel,
|
|
265
|
+
): Promise<boolean> {
|
|
266
|
+
if (this.options.submission.origin !== "scheduled") return false;
|
|
267
|
+
this.assertUnattendedActive();
|
|
268
|
+
const spec = candidate.unattendedExecution;
|
|
269
|
+
if (candidate.interaction) {
|
|
270
|
+
return this.denyUnattended(
|
|
271
|
+
`Unattended execution requires human input for ${candidate.tool.name}: ${stableInput(input)}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (candidate.alwaysRequiresApproval) {
|
|
275
|
+
return this.denyUnattended(
|
|
276
|
+
`Unattended execution denied for ${candidate.tool.name}: this operation requires approval for every call`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (!spec) {
|
|
280
|
+
return this.denyUnattended(
|
|
281
|
+
`Unattended execution denied for ${candidate.tool.name}: this Tool has no pre-authorizable operation contract`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const grant = this.options.submission.unattendedExecutionGrant;
|
|
285
|
+
const authorize = this.options.authorizeUnattendedExecution;
|
|
286
|
+
if (!grant || !authorize) {
|
|
287
|
+
const reason =
|
|
288
|
+
`Unattended execution is not authorized for ${spec.capability}.${spec.operation}`;
|
|
289
|
+
await this.options.onUnattendedFailure?.(reason);
|
|
290
|
+
throw new UnattendedExecutionError(reason);
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
await authorize({
|
|
294
|
+
grant,
|
|
295
|
+
capability: spec.capability,
|
|
296
|
+
operation: spec.operation,
|
|
297
|
+
...(spec.objectForInput
|
|
298
|
+
? { object: await spec.objectForInput(input) }
|
|
299
|
+
: {}),
|
|
300
|
+
toolCallId: invocation.toolCallId,
|
|
301
|
+
toolName: candidate.tool.name,
|
|
302
|
+
input,
|
|
303
|
+
requiredExecutionLevel,
|
|
304
|
+
signal: invocation.signal,
|
|
305
|
+
});
|
|
306
|
+
return true;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
309
|
+
const message =
|
|
310
|
+
`Unattended execution denied for ${spec.capability}.${spec.operation}: ${reason}`;
|
|
311
|
+
await this.options.onUnattendedFailure?.(message);
|
|
312
|
+
throw new UnattendedExecutionError(message, { cause: error });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private async denyUnattended(reason: string): Promise<never> {
|
|
317
|
+
await this.options.onUnattendedFailure?.(reason);
|
|
318
|
+
throw new UnattendedExecutionError(reason);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
resolveConfirmation(candidate: PiToolCandidate, toolCallId: string, args: unknown): { prepared: unknown; intentId: string } {
|
|
322
|
+
const intentId = `${this.options.submission.submissionId}:${toolCallId}`;
|
|
323
|
+
const approval = this.options.approvals.find(intentId);
|
|
324
|
+
if (!approval || approval.status !== "approved" || !approval.confirmationJson ||
|
|
325
|
+
approval.toolName !== candidate.tool.name || approval.inputJson !== stableInput(args)) {
|
|
326
|
+
throw new Error("Matching user confirmation is required");
|
|
327
|
+
}
|
|
328
|
+
return { prepared: JSON.parse(approval.confirmationJson), intentId };
|
|
194
329
|
}
|
|
195
330
|
|
|
196
331
|
resolveReplay(
|
package/src/tools/declared.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface PiDeclaredToolPolicy<Reply = unknown> {
|
|
|
49
49
|
readonly modelName: string;
|
|
50
50
|
readonly requiredExecutionLevel: PiToolCandidate["requiredExecutionLevel"];
|
|
51
51
|
readonly requiredExecutionLevelForInput?: PiToolCandidate["requiredExecutionLevelForInput"];
|
|
52
|
+
readonly unattendedExecution?: PiToolCandidate["unattendedExecution"];
|
|
52
53
|
readonly exposureMode?: PiToolCandidate["exposureMode"];
|
|
53
54
|
readonly retry?: PiToolCandidate["retry"];
|
|
54
55
|
readonly source?: PiToolCandidate["source"];
|
|
@@ -117,6 +118,9 @@ export function createPiDeclaredToolCandidate<Reply = unknown>(
|
|
|
117
118
|
...(policy.requiredExecutionLevelForInput
|
|
118
119
|
? { requiredExecutionLevelForInput: policy.requiredExecutionLevelForInput }
|
|
119
120
|
: {}),
|
|
121
|
+
...(policy.unattendedExecution
|
|
122
|
+
? { unattendedExecution: policy.unattendedExecution }
|
|
123
|
+
: {}),
|
|
120
124
|
...(policy.exposureMode ? { exposureMode: policy.exposureMode } : {}),
|
|
121
125
|
...(policy.retry ? { retry: policy.retry } : {}),
|
|
122
126
|
...(policy.source ? { source: policy.source } : {}),
|
package/src/tools/schedule.ts
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
+
import { isValidTimeZone } from "../adapter/cloudflare/scheduling/zoned-cron";
|
|
2
|
+
import { validateScheduleRuleSpec, toScheduleRuleSpec } from "../adapter/cloudflare/scheduling/schedule-rule";
|
|
1
3
|
import type {
|
|
2
4
|
AgentTool,
|
|
3
5
|
AgentToolResult,
|
|
4
6
|
} from "@earendil-works/pi-agent-core";
|
|
5
|
-
import { Type, type TSchema } from "@earendil-works/pi-ai";
|
|
7
|
+
import { fauxToolCall, validateToolArguments, Type, type TSchema } from "@earendil-works/pi-ai";
|
|
6
8
|
import type {
|
|
7
9
|
RuntimeSchedulePort,
|
|
8
|
-
|
|
10
|
+
RuntimeSchedulePreparation,
|
|
9
11
|
} from "../config/ports";
|
|
10
12
|
import type { ScheduleSpec } from "../submission/contracts";
|
|
11
13
|
import { serializeOutput } from "../lib/artifacts";
|
|
12
14
|
import type { PiToolCandidate } from "./compiler";
|
|
13
15
|
|
|
14
16
|
const scheduleTriggerParameters = Type.Union([
|
|
17
|
+
Type.Object({
|
|
18
|
+
kind: Type.Literal("local", { description: "Interpret the rule in the explicit task timezone." }),
|
|
19
|
+
rule: Type.Union([
|
|
20
|
+
Type.Object({ kind: Type.Literal("once", { description: "Run on one local date." }), date: Type.String({ description: "Local date YYYY-MM-DD." }), time: Type.String({ description: "Local clock time HH:mm." }) }, { additionalProperties: false }),
|
|
21
|
+
Type.Object({ kind: Type.Literal("daily", { description: "Run each local day." }), time: Type.String({ description: "Local clock time HH:mm." }) }, { additionalProperties: false }),
|
|
22
|
+
Type.Object({ kind: Type.Literal("weekly", { description: "Run on the selected weekdays." }), weekdays: Type.Array(Type.Integer({ minimum: 0, maximum: 6 }), { minItems: 1, description: "Weekdays: Sunday 0 through Saturday 6." }), time: Type.String({ description: "Local clock time HH:mm." }) }, { additionalProperties: false }),
|
|
23
|
+
], { description: "Once, daily or weekly local-time rule." }),
|
|
24
|
+
startDate: Type.Optional(Type.String({ description: "Inclusive first local date YYYY-MM-DD." })),
|
|
25
|
+
endDate: Type.Optional(Type.String({ description: "Inclusive last local date YYYY-MM-DD." })),
|
|
26
|
+
}, { additionalProperties: false }),
|
|
15
27
|
Type.Object({
|
|
16
28
|
kind: Type.Literal("once-in"),
|
|
17
29
|
seconds: Type.Integer({
|
|
@@ -43,19 +55,13 @@ const scheduleTriggerParameters = Type.Union([
|
|
|
43
55
|
], { description: "When to fire." });
|
|
44
56
|
|
|
45
57
|
const scheduleParameters = Type.Object({
|
|
46
|
-
prompt: Type.String({
|
|
58
|
+
prompt: Type.String({ minLength: 1, maxLength: 1000, pattern: "\\S",
|
|
47
59
|
description:
|
|
48
60
|
"The standalone instruction to run when the schedule fires. The future turn has no context from this chat, so make it self-contained.",
|
|
49
61
|
}),
|
|
50
62
|
trigger: scheduleTriggerParameters,
|
|
51
|
-
label: Type.String({
|
|
52
|
-
|
|
53
|
-
"A short human-readable name for this task (e.g. '每日周报提醒'). Required — it is how the user recognises the task in their schedule list and how they will refer to it when asking you to cancel it later.",
|
|
54
|
-
}),
|
|
55
|
-
timezone: Type.Optional(Type.String({
|
|
56
|
-
description:
|
|
57
|
-
"IANA timezone the clock times in `trigger` should be interpreted in, e.g. 'Asia/Shanghai'. Pass the user's own timezone whenever they state a wall-clock time. Omit only for delays and intervals. Defaults to UTC.",
|
|
58
|
-
})),
|
|
63
|
+
label: Type.Optional(Type.String({ maxLength: 50, description: "Optional task name, at most 50 Unicode characters. The Host generates a name when omitted." })),
|
|
64
|
+
timezone: Type.String({ minLength: 1, description: "Required IANA timezone. Use the user's explicit timezone or reliable Host/browser timezone; ask when unknown." }),
|
|
59
65
|
}, { additionalProperties: false });
|
|
60
66
|
|
|
61
67
|
const noParameters = Type.Object({}, { additionalProperties: false });
|
|
@@ -65,11 +71,11 @@ const scheduleIdParameters = Type.Object({
|
|
|
65
71
|
}),
|
|
66
72
|
}, { additionalProperties: false });
|
|
67
73
|
const updateScheduleParameters = Type.Object({
|
|
68
|
-
id: Type.String({ description: "The exact id returned by `list_schedules`." }),
|
|
69
|
-
prompt: Type.Optional(
|
|
74
|
+
id: Type.String({ minLength: 1, description: "The exact id returned by `list_schedules`." }),
|
|
75
|
+
prompt: Type.Optional(scheduleParameters.properties.prompt),
|
|
70
76
|
trigger: Type.Optional(scheduleTriggerParameters),
|
|
71
|
-
label:
|
|
72
|
-
timezone: Type.Optional(
|
|
77
|
+
label: scheduleParameters.properties.label,
|
|
78
|
+
timezone: Type.Optional(scheduleParameters.properties.timezone),
|
|
73
79
|
}, { additionalProperties: false });
|
|
74
80
|
const changeScheduleAgentParameters = Type.Object({
|
|
75
81
|
id: Type.String({
|
|
@@ -113,24 +119,17 @@ function candidate<T extends TSchema>(
|
|
|
113
119
|
export function schedulePiToolCandidates(
|
|
114
120
|
schedule: RuntimeSchedulePort,
|
|
115
121
|
): PiToolCandidate[] {
|
|
116
|
-
|
|
122
|
+
const candidates = [
|
|
117
123
|
candidate(
|
|
118
124
|
{
|
|
119
125
|
name: "schedule",
|
|
120
126
|
label: "Schedule prompt",
|
|
121
127
|
description:
|
|
122
|
-
"Schedule a prompt to run LATER, autonomously. Use when the user asks to be reminded, or to run something after a delay / at a time / on a recurring basis. The user always reviews the proposed schedule before it is created. The scheduled prompt runs in its own dedicated session at each trigger with NO memory of this conversation, so phrase `prompt` as a fully standalone instruction. After scheduling, tell the user what you set.",
|
|
128
|
+
"Schedule a prompt to run LATER, autonomously. Required inputs: prompt (Requirement, at most 1000 Unicode characters), trigger and timezone. Prefer local once/daily/weekly rules. label is optional (at most 50 characters); the Host generates an omitted name. Ask for missing information. Use when the user asks to be reminded, or to run something after a delay / at a time / on a recurring basis. The user always reviews the proposed schedule before it is created. The scheduled prompt runs in its own dedicated session at each trigger with NO memory of this conversation, so phrase `prompt` as a fully standalone instruction. After scheduling, tell the user what you set.",
|
|
123
129
|
parameters: scheduleParameters,
|
|
124
130
|
async execute(_toolCallId, input, signal) {
|
|
125
131
|
signal?.throwIfAborted();
|
|
126
|
-
|
|
127
|
-
trigger: input.trigger,
|
|
128
|
-
job: { kind: "prompt", prompt: input.prompt },
|
|
129
|
-
label: input.label,
|
|
130
|
-
...(input.timezone ? { tz: input.timezone } : {}),
|
|
131
|
-
};
|
|
132
|
-
const { id } = await schedule.create(spec);
|
|
133
|
-
return result({ scheduled: true, id });
|
|
132
|
+
throw new Error("Matching user confirmation is required");
|
|
134
133
|
},
|
|
135
134
|
},
|
|
136
135
|
{
|
|
@@ -157,18 +156,11 @@ export function schedulePiToolCandidates(
|
|
|
157
156
|
name: "update_schedule",
|
|
158
157
|
label: "Update schedule",
|
|
159
158
|
description:
|
|
160
|
-
"Update one scheduled task without changing its id or history. Call `list_schedules` first and use an exact id. If multiple tasks plausibly match the user's wording, ask which one instead of guessing.",
|
|
159
|
+
"Update one scheduled task without changing its id or history. Required input: id. Optional prompt, trigger, label and timezone preserve the saved values when omitted. Every edit requires user confirmation; changing Requirement requires a fresh Host authorization check. Call `list_schedules` first and use an exact id. If multiple tasks plausibly match the user's wording, ask which one instead of guessing.",
|
|
161
160
|
parameters: updateScheduleParameters,
|
|
162
161
|
async execute(_toolCallId, input, signal) {
|
|
163
162
|
signal?.throwIfAborted();
|
|
164
|
-
|
|
165
|
-
...(input.prompt !== undefined ? { prompt: input.prompt } : {}),
|
|
166
|
-
...(input.trigger !== undefined ? { trigger: input.trigger } : {}),
|
|
167
|
-
...(input.label !== undefined ? { label: input.label } : {}),
|
|
168
|
-
...(input.timezone !== undefined ? { tz: input.timezone } : {}),
|
|
169
|
-
};
|
|
170
|
-
const { ok } = await schedule.update(input.id, changes);
|
|
171
|
-
return result({ updated: ok, id: input.id });
|
|
163
|
+
throw new Error("Matching user confirmation is required");
|
|
172
164
|
},
|
|
173
165
|
}),
|
|
174
166
|
candidate({
|
|
@@ -256,6 +248,46 @@ export function schedulePiToolCandidates(
|
|
|
256
248
|
{ owner: "schedule-host" },
|
|
257
249
|
),
|
|
258
250
|
];
|
|
251
|
+
return candidates.flatMap((toolCandidate): PiToolCandidate[] => {
|
|
252
|
+
const name = toolCandidate.tool.name;
|
|
253
|
+
if (name !== "schedule" && name !== "update_schedule") return [toolCandidate];
|
|
254
|
+
const confirmation = schedule.confirmation;
|
|
255
|
+
if (!confirmation) return [];
|
|
256
|
+
return [{ ...toolCandidate, alwaysRequiresApproval: true, confirmation: {
|
|
257
|
+
async prepare(args, { intentId }) {
|
|
258
|
+
validateToolArguments(toolCandidate.tool, fauxToolCall(name, args as Record<string, unknown>));
|
|
259
|
+
const input = args as { id?: string; prompt?: string; trigger?: ScheduleSpec["trigger"]; label?: string; timezone?: string };
|
|
260
|
+
if (input.timezone !== undefined && !isValidTimeZone(input.timezone)) throw new Error("A valid IANA timezone is required");
|
|
261
|
+
if (input.trigger?.kind === "local" && input.timezone) {
|
|
262
|
+
const error = validateScheduleRuleSpec(toScheduleRuleSpec(input.trigger, input.timezone));
|
|
263
|
+
if (error) throw new Error(error.message);
|
|
264
|
+
}
|
|
265
|
+
const prepared = await confirmation.prepare({ intentId, ...(name === "update_schedule" ? { id: input.id } : {}),
|
|
266
|
+
...(input.prompt !== undefined ? { prompt: input.prompt } : {}),
|
|
267
|
+
...(input.trigger !== undefined ? { trigger: input.trigger } : {}),
|
|
268
|
+
...(input.label !== undefined ? { label: input.label } : {}),
|
|
269
|
+
...(input.timezone !== undefined ? { tz: input.timezone } : {}),
|
|
270
|
+
});
|
|
271
|
+
if (prepared.id !== input.id) throw new Error("Host preparation changed the target Task ID");
|
|
272
|
+
const fullInput = { prompt: prepared.prompt, label: prepared.label, trigger: prepared.trigger, timezone: prepared.timezone };
|
|
273
|
+
validateToolArguments({ ...toolCandidate.tool, parameters: scheduleParameters }, fauxToolCall(name, fullInput));
|
|
274
|
+
if (!prepared.label?.trim() || !isValidTimeZone(prepared.timezone) || !prepared.candidateId?.trim() || !prepared.candidateVersion?.trim() || !prepared.authorization?.version?.trim() || !prepared.authorization.scope?.trim() || !Array.isArray(prepared.authorization.missing)) {
|
|
275
|
+
throw new Error("Host preparation is incomplete");
|
|
276
|
+
}
|
|
277
|
+
if (prepared.trigger.kind === "local") {
|
|
278
|
+
const error = validateScheduleRuleSpec(toScheduleRuleSpec(prepared.trigger, prepared.timezone));
|
|
279
|
+
if (error) throw new Error(error.message);
|
|
280
|
+
}
|
|
281
|
+
return prepared;
|
|
282
|
+
},
|
|
283
|
+
async execute(prepared, context) {
|
|
284
|
+
const preview = prepared as RuntimeSchedulePreparation;
|
|
285
|
+
if (preview.authorization.missing.length) throw new Error(`Missing authorization: ${preview.authorization.missing.join("; ")}`);
|
|
286
|
+
const { id } = await confirmation.commit(preview, context);
|
|
287
|
+
return result({ [name === "schedule" ? "scheduled" : "updated"]: true, id, task: { label: preview.label, prompt: preview.prompt, trigger: preview.trigger, timezone: preview.timezone } });
|
|
288
|
+
},
|
|
289
|
+
} }];
|
|
290
|
+
});
|
|
259
291
|
}
|
|
260
292
|
|
|
261
293
|
/** 从 {@link RuntimeSchedulePort} 生成定时任务 Tool 集。 */
|