@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.20
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 +11 -9
- package/src/adapter/cloudflare/agent.ts +177 -122
- package/src/adapter/cloudflare/define-agent.ts +47 -26
- 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 +15 -0
- package/src/adapter/cloudflare/subagent/definition.ts +25 -13
- package/src/adapter/cloudflare/subagent/runner.ts +43 -50
- package/src/adapter/cloudflare/subagent/tools.ts +4 -4
- package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
- package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
- package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
- package/src/config/agent-context.ts +1 -1
- package/src/config/assembler.ts +48 -31
- package/src/config/assembly-snapshot.ts +1 -1
- package/src/config/definition.ts +1 -1
- package/src/config/harness-runtime.ts +51 -13
- package/src/config/model-guidance.ts +20 -0
- package/src/config/pinning.ts +39 -5
- package/src/config/ports.ts +57 -7
- package/src/config/public-contracts.ts +2 -2
- package/src/context/assembly.ts +1 -1
- package/src/context/extension/assembly.ts +2 -2
- package/src/context/memory/budget/gate.ts +19 -3
- package/src/conversation/harness-resume.ts +89 -238
- package/src/conversation/harness-stream.ts +87 -71
- package/src/conversation/history.ts +154 -152
- package/src/conversation/message/contract.ts +12 -0
- package/src/conversation/message/conversion.ts +3 -3
- package/src/conversation/message/projection.ts +61 -21
- package/src/conversation/session-entry.ts +29 -0
- package/src/conversation/state.ts +23 -2
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +3 -0
- package/src/harness/session-harness.ts +255 -446
- package/src/harness/submission-operation.ts +15 -40
- package/src/index.ts +18 -8
- package/src/lib/artifacts.ts +18 -0
- package/src/lib/execution-level.ts +13 -0
- package/src/lib/prompt.ts +9 -10
- package/src/lib/token-estimate.ts +8 -0
- package/src/model-protocol/openrouter.ts +41 -0
- package/src/models/index.ts +200 -35
- package/src/models/openrouter/messages.ts +13 -8
- package/src/models/openrouter/openai.ts +200 -0
- package/src/models/openrouter/protocol-response.ts +85 -0
- package/src/models/provider-streams.ts +45 -0
- package/src/session/cloudflare/migration.ts +382 -864
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/skills/springbrand-worker-website/index.ts +1 -1
- package/src/submission/admission.ts +12 -1
- package/src/submission/contracts.ts +42 -2
- package/src/submission/delivery.ts +1 -1
- package/src/submission/finalization.ts +36 -15
- package/src/submission/repository.ts +64 -25
- package/src/submission/steer-delivery.ts +32 -76
- package/src/submission/unattended.ts +40 -0
- package/src/telemetry/coordinator.ts +1 -1
- package/src/telemetry/subagent-lifecycle.ts +1 -1
- package/src/telemetry/turn-events.ts +35 -25
- package/src/tools/README.md +44 -0
- package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
- package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
- package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
- package/src/tools/assistant/ask-user.ts +317 -0
- package/src/tools/assistant/candidate.ts +15 -0
- package/src/tools/assistant/followups.ts +34 -0
- package/src/tools/assistant/index.ts +19 -0
- package/src/tools/assistant/memory.ts +73 -0
- package/src/tools/assistant/plan.ts +98 -0
- package/src/tools/{time.ts → assistant/time.ts} +1 -1
- package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
- package/src/tools/{core.ts → codemode/index.ts} +36 -92
- package/src/tools/codemode/workspace-connector.ts +198 -0
- package/src/tools/extension/index.ts +42 -0
- package/src/tools/index.ts +29 -26
- package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
- package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
- package/src/tools/runtime/activation.ts +40 -0
- package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
- package/src/tools/runtime/compiler.ts +246 -0
- package/src/tools/runtime/contracts.ts +140 -0
- package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
- package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
- package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
- package/src/tools/runtime/result.ts +22 -0
- package/src/tools/runtime/unattended-error.ts +4 -0
- package/src/tools/sandbox/index.ts +228 -0
- package/src/tools/schedule/index.ts +301 -0
- package/src/tools/skill/contracts.ts +20 -0
- package/src/tools/skill/index.ts +99 -0
- package/src/tools/skill/resources.ts +322 -0
- package/src/tools/skill/scripts.ts +168 -0
- package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
- package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
- package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
- package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
- package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
- package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
- package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
- package/src/tools/web/index.ts +2 -0
- package/src/tools/web/search/api.ts +1322 -0
- package/src/tools/web/search/index.ts +2 -0
- package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
- package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
- package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
- package/src/tools/base.ts +0 -534
- package/src/tools/compiler.ts +0 -327
- package/src/tools/schedule.ts +0 -261
- package/src/tools/skill.ts +0 -565
- package/src/tools/web-search/api.ts +0 -1318
- package/src/tools/web-search/index.ts +0 -2
- package/src/tools/workspace-state-connector.ts +0 -146
- package/src/tools/{approval → runtime/approval}/index.ts +0 -0
- package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
- package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
- /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { UIMessage } from "ai";
|
|
|
2
2
|
import type { PiCanonicalUserInput } from "../conversation/message/contract";
|
|
3
3
|
import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
|
|
4
4
|
import type { RuntimeTurnEvents } from "../telemetry/turn-events";
|
|
5
|
-
import type { HarnessHumanDecisions } from "../tools/human-decisions";
|
|
5
|
+
import type { HarnessHumanDecisions } from "../tools/runtime/human-decisions";
|
|
6
6
|
import type { SubmitMessageOptions } from "./admission";
|
|
7
7
|
import {
|
|
8
8
|
projectSubmissionReceipt,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
-
import type { CancelQueuedResult } from "
|
|
2
|
+
import type { CancelQueuedResult } from "./steer-delivery";
|
|
3
3
|
import type { ConversationHistory } from "../conversation/history";
|
|
4
4
|
import type { MessageUiRepository } from "../conversation/repository";
|
|
5
5
|
import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
|
|
6
6
|
import type { RuntimeTurnEvents } from "../telemetry/turn-events";
|
|
7
|
-
import type { HarnessHumanDecisions } from "../tools/human-decisions";
|
|
7
|
+
import type { HarnessHumanDecisions } from "../tools/runtime/human-decisions";
|
|
8
8
|
import { USER_STOP_REASON } from "./contracts";
|
|
9
9
|
import type { SubmissionOutcome } from "./lifecycle";
|
|
10
10
|
import { isTerminalSubmissionStatus, type StoredSubmission, type SubmissionRepository } from "./repository";
|
|
@@ -13,6 +13,8 @@ import type { SteerRepository } from "./steer-repository";
|
|
|
13
13
|
|
|
14
14
|
/** Commits the Submission outcome and human/Host facts after accepted steering is preserved. */
|
|
15
15
|
export class SubmissionFinalizer {
|
|
16
|
+
/** User-stop markers appended after their terminal commit; every `finish()` of that Turn awaits it. */
|
|
17
|
+
private readonly stopMarkers = new Map<string, Promise<unknown>>();
|
|
16
18
|
constructor(private readonly options: {
|
|
17
19
|
submissions: SubmissionRepository;
|
|
18
20
|
steers: SteerRepository;
|
|
@@ -34,7 +36,12 @@ export class SubmissionFinalizer {
|
|
|
34
36
|
message?: string,
|
|
35
37
|
): Promise<StoredSubmission> {
|
|
36
38
|
const initial = this.read(submissionId);
|
|
37
|
-
if (isTerminalSubmissionStatus(initial.status))
|
|
39
|
+
if (isTerminalSubmissionStatus(initial.status)) {
|
|
40
|
+
// A stop RPC and the ending harness finish the same Turn; whichever loses
|
|
41
|
+
// still waits for the stop marker, so its history broadcast carries it.
|
|
42
|
+
await this.stopMarkers.get(submissionId);
|
|
43
|
+
return initial;
|
|
44
|
+
}
|
|
38
45
|
const aborted = Boolean(initial.abortReason) || outcome === "aborted";
|
|
39
46
|
if (aborted) await this.options.releaseAbortedHarness();
|
|
40
47
|
await finishPendingSteers({
|
|
@@ -53,7 +60,13 @@ export class SubmissionFinalizer {
|
|
|
53
60
|
const output = (await this.options.conversation.storedMessages())
|
|
54
61
|
.filter((entry) => entry.submissionId === submissionId && entry.message.role === "assistant")
|
|
55
62
|
.at(-1)?.message;
|
|
56
|
-
|
|
63
|
+
const completed = this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
|
|
64
|
+
const marker = this.stopMarkers.get(submissionId);
|
|
65
|
+
if (marker) {
|
|
66
|
+
await marker;
|
|
67
|
+
this.stopMarkers.delete(submissionId);
|
|
68
|
+
}
|
|
69
|
+
return completed;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
/** Shares atomic terminal facts with an admitted handoff cancelled before execution. */
|
|
@@ -63,7 +76,8 @@ export class SubmissionFinalizer {
|
|
|
63
76
|
message?: string,
|
|
64
77
|
output?: AssistantMessage,
|
|
65
78
|
): StoredSubmission {
|
|
66
|
-
|
|
79
|
+
let stoppedByUser = false;
|
|
80
|
+
const completed = this.options.transaction(() => {
|
|
67
81
|
// Host admission and history reads can await while an explicit stop wins.
|
|
68
82
|
const latest = this.read(submissionId);
|
|
69
83
|
if (isTerminalSubmissionStatus(latest.status)) return latest;
|
|
@@ -79,16 +93,9 @@ export class SubmissionFinalizer {
|
|
|
79
93
|
const status = effectiveOutcome === "succeeded" ? "completed" : effectiveOutcome === "aborted" ? "aborted" : "error";
|
|
80
94
|
this.options.submissions.updateTerminal(submissionId, status, reason, Date.now());
|
|
81
95
|
const completed = this.read(submissionId);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`Aborted SpringBrand submission has no completion time: ${submissionId}`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
this.options.conversation.appendTurnAbortedMarker(
|
|
89
|
-
submissionId,
|
|
90
|
-
completed.completedAt,
|
|
91
|
-
);
|
|
96
|
+
stoppedByUser = effectiveOutcome === "aborted" && reason === USER_STOP_REASON;
|
|
97
|
+
if (stoppedByUser && completed.completedAt === null) {
|
|
98
|
+
throw new Error(`Aborted SpringBrand submission has no completion time: ${submissionId}`);
|
|
92
99
|
}
|
|
93
100
|
this.options.events.submissionTerminal(completed);
|
|
94
101
|
this.options.telemetry.capture("turnFinished", {
|
|
@@ -101,6 +108,20 @@ export class SubmissionFinalizer {
|
|
|
101
108
|
});
|
|
102
109
|
return completed;
|
|
103
110
|
});
|
|
111
|
+
if (stoppedByUser) this.appendStopMarker(submissionId, completed.completedAt!);
|
|
112
|
+
return completed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The browser projects `interruptedByUser` from this marker and the next model request sees it as user context.
|
|
117
|
+
* Pi history writes are async, so the marker follows the synchronous terminal commit.
|
|
118
|
+
*/
|
|
119
|
+
private appendStopMarker(submissionId: string, completedAt: number): void {
|
|
120
|
+
const marker = this.options.conversation.appendTurnAbortedMarker(submissionId, completedAt).catch((error: unknown) => {
|
|
121
|
+
console.warn("[runtime-stop-marker:failed]", submissionId, error instanceof Error ? error.message : String(error));
|
|
122
|
+
});
|
|
123
|
+
this.stopMarkers.set(submissionId, marker);
|
|
124
|
+
this.options.waitUntil(marker);
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
private read(submissionId: string): StoredSubmission {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { SqlTaggedTemplate } from "agents/chat";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import type { ExecutionLevel } from "../lib/execution-level";
|
|
4
|
+
import type { RuntimeUnattendedExecutionGrant } from "./unattended";
|
|
3
5
|
|
|
4
6
|
// Submission 的统一定义见 ./index.ts。
|
|
5
7
|
// #region 状态与行类型
|
|
@@ -11,6 +13,14 @@ export type SubmissionStatus =
|
|
|
11
13
|
| "skipped"
|
|
12
14
|
| "error";
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 谁发起了这条 Submission。
|
|
18
|
+
*
|
|
19
|
+
* `scheduled` 是定时 Run 的注入:它在本 Session 未结束期间独占输入,
|
|
20
|
+
* 普通 `user` Submission 必须被拒绝,而不是排队等它结束。
|
|
21
|
+
*/
|
|
22
|
+
export type SubmissionOrigin = "user" | "scheduled";
|
|
23
|
+
|
|
14
24
|
const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
|
|
15
25
|
"completed",
|
|
16
26
|
"aborted",
|
|
@@ -46,6 +56,10 @@ export interface StoredSubmission {
|
|
|
46
56
|
userMessageId: string | null;
|
|
47
57
|
regenerateMessageId: string | null;
|
|
48
58
|
runId: string | null;
|
|
59
|
+
origin: SubmissionOrigin;
|
|
60
|
+
scheduledMaxExecutionLevel: ExecutionLevel | null;
|
|
61
|
+
unattendedExecutionGrant: RuntimeUnattendedExecutionGrant | null;
|
|
62
|
+
unattendedFailure: string | null;
|
|
49
63
|
retryable?: boolean;
|
|
50
64
|
}
|
|
51
65
|
|
|
@@ -69,6 +83,9 @@ export interface NewSubmission {
|
|
|
69
83
|
userMessageId?: string | null;
|
|
70
84
|
regenerateMessageId?: string | null;
|
|
71
85
|
runId?: string | null;
|
|
86
|
+
origin?: SubmissionOrigin;
|
|
87
|
+
scheduledMaxExecutionLevel?: ExecutionLevel;
|
|
88
|
+
unattendedExecutionGrant?: RuntimeUnattendedExecutionGrant;
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
type SubmissionRow = {
|
|
@@ -90,7 +107,11 @@ type SubmissionRow = {
|
|
|
90
107
|
user_message_id: string | null;
|
|
91
108
|
regenerate_message_id: string | null;
|
|
92
109
|
run_id: string | null;
|
|
110
|
+
origin: string | null;
|
|
111
|
+
scheduled_max_execution_level: string | null;
|
|
93
112
|
admission_retryable: number | null;
|
|
113
|
+
unattended_execution_grant_json: string | null;
|
|
114
|
+
unattended_failure: string | null;
|
|
94
115
|
};
|
|
95
116
|
|
|
96
117
|
// #endregion
|
|
@@ -118,6 +139,12 @@ function mapRow(row: SubmissionRow): StoredSubmission {
|
|
|
118
139
|
userMessageId: row.user_message_id,
|
|
119
140
|
regenerateMessageId: row.regenerate_message_id,
|
|
120
141
|
runId: row.run_id,
|
|
142
|
+
origin: row.origin === "scheduled" ? "scheduled" : "user",
|
|
143
|
+
scheduledMaxExecutionLevel: row.scheduled_max_execution_level as ExecutionLevel | null,
|
|
144
|
+
unattendedExecutionGrant: row.unattended_execution_grant_json
|
|
145
|
+
? JSON.parse(row.unattended_execution_grant_json) as RuntimeUnattendedExecutionGrant
|
|
146
|
+
: null,
|
|
147
|
+
unattendedFailure: row.unattended_failure,
|
|
121
148
|
retryable: row.admission_retryable === null
|
|
122
149
|
? undefined
|
|
123
150
|
: row.admission_retryable === 1,
|
|
@@ -150,7 +177,11 @@ export class SubmissionRepository {
|
|
|
150
177
|
queued_input_json, queued_ui_message_json, user_message_id,
|
|
151
178
|
regenerate_message_id,
|
|
152
179
|
run_id,
|
|
153
|
-
|
|
180
|
+
origin,
|
|
181
|
+
scheduled_max_execution_level,
|
|
182
|
+
admission_retryable,
|
|
183
|
+
unattended_execution_grant_json,
|
|
184
|
+
unattended_failure
|
|
154
185
|
FROM pi_submissions
|
|
155
186
|
WHERE submission_id = ${id}
|
|
156
187
|
`[0];
|
|
@@ -251,7 +282,7 @@ export class SubmissionRepository {
|
|
|
251
282
|
created_at, assembly_revision, assembly_descriptor, runtime_key,
|
|
252
283
|
assistant_message_id, queued_input_json, queued_ui_message_json,
|
|
253
284
|
user_message_id, regenerate_message_id,
|
|
254
|
-
run_id
|
|
285
|
+
run_id, origin, scheduled_max_execution_level, unattended_execution_grant_json
|
|
255
286
|
) VALUES (
|
|
256
287
|
${s.submissionId},
|
|
257
288
|
${s.requestId},
|
|
@@ -267,11 +298,31 @@ export class SubmissionRepository {
|
|
|
267
298
|
${s.queuedUiMessageJson ?? null},
|
|
268
299
|
${s.userMessageId ?? null},
|
|
269
300
|
${s.regenerateMessageId ?? null},
|
|
270
|
-
${s.runId ?? s.submissionId}
|
|
301
|
+
${s.runId ?? s.submissionId},
|
|
302
|
+
${s.origin ?? "user"},
|
|
303
|
+
${s.scheduledMaxExecutionLevel ?? null},
|
|
304
|
+
${s.unattendedExecutionGrant
|
|
305
|
+
? JSON.stringify(s.unattendedExecutionGrant)
|
|
306
|
+
: null}
|
|
271
307
|
)
|
|
272
308
|
`;
|
|
273
309
|
}
|
|
274
310
|
|
|
311
|
+
/**
|
|
312
|
+
* 是否存在一条未结束的定时 Submission。
|
|
313
|
+
*
|
|
314
|
+
* 定时 Run 未结束时它独占本 Session 的输入:普通消息在准入前被拒绝,
|
|
315
|
+
* 不排队等待。Run 进入终态后查询自然为空,普通追问恢复。
|
|
316
|
+
*/
|
|
317
|
+
hasUnfinishedScheduled(): boolean {
|
|
318
|
+
return (
|
|
319
|
+
this.sql<{ count: number }>`
|
|
320
|
+
SELECT COUNT(*) AS count FROM pi_submissions
|
|
321
|
+
WHERE status IN ('pending', 'running') AND origin = 'scheduled'
|
|
322
|
+
`[0]?.count ?? 0
|
|
323
|
+
) > 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
275
326
|
insertRejected(s: {
|
|
276
327
|
submissionId: string;
|
|
277
328
|
requestId: string;
|
|
@@ -310,6 +361,15 @@ export class SubmissionRepository {
|
|
|
310
361
|
return updated.length === 1;
|
|
311
362
|
}
|
|
312
363
|
|
|
364
|
+
recordUnattendedFailure(id: string, reason: string): void {
|
|
365
|
+
this.sql`
|
|
366
|
+
UPDATE pi_submissions
|
|
367
|
+
SET unattended_failure = COALESCE(unattended_failure, ${reason})
|
|
368
|
+
WHERE submission_id = ${id}
|
|
369
|
+
AND status IN ('pending', 'running')
|
|
370
|
+
`;
|
|
371
|
+
}
|
|
372
|
+
|
|
313
373
|
// 只在当前状态属于 from 时把 Submission 切换到 to,否则返回 false。
|
|
314
374
|
// SubmissionLifecycle 开始 Turn 时调用,调用方用返回值识别过期或重复的状态切换。
|
|
315
375
|
// Cloudflare SQLite 查询是同步的,两条语句之间不会让其他事件交错;UPDATE 再次带旧状态条件,不能去掉这层幂等保护。
|
|
@@ -386,27 +446,6 @@ export class SubmissionRepository {
|
|
|
386
446
|
`;
|
|
387
447
|
}
|
|
388
448
|
|
|
389
|
-
/** Recover ownership from the matching Pi operation, using only its active branch. */
|
|
390
|
-
associateSessionEntries(
|
|
391
|
-
submissionId: string,
|
|
392
|
-
snapshot: Pick<LaneSnapshot, "operation" | "lastResult" | "tipId" | "transcript">,
|
|
393
|
-
): void {
|
|
394
|
-
const boundary = snapshot.operation?.id === submissionId
|
|
395
|
-
? { fromTipId: snapshot.operation.fromTipId, tipId: snapshot.tipId }
|
|
396
|
-
: snapshot.lastResult?.operationId === submissionId
|
|
397
|
-
? snapshot.lastResult
|
|
398
|
-
: undefined;
|
|
399
|
-
if (!boundary) return;
|
|
400
|
-
const start = boundary.fromTipId === null
|
|
401
|
-
? 0
|
|
402
|
-
: snapshot.transcript.findIndex(({ id }) => id === boundary.fromTipId) + 1;
|
|
403
|
-
const end = boundary.tipId === null
|
|
404
|
-
? snapshot.transcript.length
|
|
405
|
-
: snapshot.transcript.findIndex(({ id }) => id === boundary.tipId) + 1;
|
|
406
|
-
for (const entry of snapshot.transcript.slice(start, end || undefined)) {
|
|
407
|
-
this.linkSessionEntry(entry.id, submissionId);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
449
|
|
|
411
450
|
listSessionEntrySubmissionLinks(): Map<string, string> {
|
|
412
451
|
const rows = this.sql<{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SubmissionRecord } from "@earendil-works/pi-durable";
|
|
2
|
+
export type CancelQueuedResult = { kind: "cancelled" | "already_consumed" | "not_found" };
|
|
2
3
|
import type { UIMessage } from "ai";
|
|
3
4
|
import type { ConversationHistory } from "../conversation/history";
|
|
4
5
|
import type { SubmitMessageOptions } from "./admission";
|
|
@@ -12,7 +13,8 @@ interface PendingSteerDeliveryOptions {
|
|
|
12
13
|
steers: SteerRepository;
|
|
13
14
|
messageUi: MessageUiRepository;
|
|
14
15
|
transaction<T>(run: () => T): T;
|
|
15
|
-
enqueue(
|
|
16
|
+
enqueue(steer: PendingSteer): Promise<SubmissionRecord>;
|
|
17
|
+
find(messageId: string): Promise<SubmissionRecord | undefined>;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** Reconciles Host steer receipts with Pi queue identities, without owning execution. */
|
|
@@ -20,66 +22,27 @@ export class PendingSteerDelivery {
|
|
|
20
22
|
constructor(private readonly options: PendingSteerDeliveryOptions) {}
|
|
21
23
|
|
|
22
24
|
async queue(steer: PendingSteer): Promise<void> {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
);
|
|
26
|
-
this.link(steer, entryId);
|
|
25
|
+
const record = await this.options.enqueue(steer);
|
|
26
|
+
this.options.transaction(() => this.options.steers.linkHarnessEntry(steer.messageId, String(record.id)));
|
|
27
|
+
this.settleRecord(record);
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const steer = this.options.steers.findByHarnessEntryId(entryId);
|
|
29
|
+
settleRecord(record: SubmissionRecord): boolean {
|
|
30
|
+
if (!record.requestId?.startsWith("steer:") || record.entry === undefined) return false;
|
|
31
|
+
const steer = this.options.steers.findByMessageId(record.requestId.slice(6));
|
|
32
32
|
if (!steer) return false;
|
|
33
33
|
this.options.transaction(() => {
|
|
34
|
-
this.
|
|
34
|
+
if (steer.uiMessageJson) this.options.messageUi.upsert(String(record.entry), steer.uiMessageJson);
|
|
35
35
|
this.options.steers.deleteByMessageId(steer.messageId);
|
|
36
36
|
});
|
|
37
37
|
return true;
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const transcriptIds = new Set(snapshot.transcript.map(({ id }) => id));
|
|
47
|
-
const linkedIds = new Set(pending.flatMap(({ harnessEntryId }) =>
|
|
48
|
-
harnessEntryId ? [harnessEntryId] : []
|
|
49
|
-
));
|
|
50
|
-
const unlinked = snapshot.queues.filter((entry) =>
|
|
51
|
-
entry.kind === "steer" && entry.type === "message" &&
|
|
52
|
-
!linkedIds.has(entry.entryId)
|
|
53
|
-
);
|
|
54
|
-
// Both durable queues preserve acceptance order; identical text is not identity.
|
|
55
|
-
for (const steer of pending) {
|
|
56
|
-
if (steer.harnessEntryId && transcriptIds.has(steer.harnessEntryId)) {
|
|
57
|
-
this.settle(steer.harnessEntryId);
|
|
58
|
-
} else if (steer.harnessEntryId && queuedIds.has(steer.harnessEntryId)) {
|
|
59
|
-
this.options.transaction(() =>
|
|
60
|
-
this.persistSidecar(steer, steer.harnessEntryId!)
|
|
61
|
-
);
|
|
62
|
-
} else {
|
|
63
|
-
const queued = !steer.harnessEntryId ? unlinked.shift() : undefined;
|
|
64
|
-
if (queued) this.link(steer, queued.entryId);
|
|
65
|
-
else await this.queue(steer);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
private link(steer: PendingSteer, entryId: string): void {
|
|
71
|
-
this.options.transaction(() => {
|
|
72
|
-
if (steer.harnessEntryId && steer.harnessEntryId !== entryId) {
|
|
73
|
-
this.options.messageUi.delete(steer.harnessEntryId);
|
|
74
|
-
}
|
|
75
|
-
this.options.steers.linkHarnessEntry(steer.messageId, entryId);
|
|
76
|
-
this.persistSidecar(steer, entryId);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
private persistSidecar(steer: PendingSteer, entryId: string): void {
|
|
81
|
-
if (steer.uiMessageJson) {
|
|
82
|
-
this.options.messageUi.upsert(entryId, steer.uiMessageJson);
|
|
39
|
+
async reconcile(submissionId: string): Promise<void> {
|
|
40
|
+
for (const steer of this.options.steers.listForSubmission(submissionId)) {
|
|
41
|
+
const existing = await this.options.find(steer.messageId);
|
|
42
|
+
if (existing) {
|
|
43
|
+
this.options.transaction(() => this.options.steers.linkHarnessEntry(steer.messageId, String(existing.id)));
|
|
44
|
+
this.settleRecord(existing);
|
|
45
|
+
} else await this.queue(steer);
|
|
83
46
|
}
|
|
84
47
|
}
|
|
85
48
|
}
|
|
@@ -109,20 +72,18 @@ export async function finishPendingSteers(options: {
|
|
|
109
72
|
: undefined;
|
|
110
73
|
const requestId = `${options.submissionId}:steer:${steer.steerId}`;
|
|
111
74
|
if (options.aborted) {
|
|
112
|
-
options.transaction(() => {
|
|
75
|
+
const owner = options.transaction(() => {
|
|
113
76
|
let handoff = options.submissions.findByRequestId(requestId);
|
|
114
77
|
if (handoff?.status === "pending") {
|
|
115
78
|
const reason = submissionAbortReason(options.submissions.find(options.submissionId)) ?? "Cancelled";
|
|
116
79
|
options.submissions.updateAbortReason(handoff.submissionId, reason);
|
|
117
80
|
handoff = options.cancelHandoff(handoff.submissionId, reason);
|
|
118
81
|
}
|
|
82
|
+
return handoff?.status === "aborted" || handoff?.status === "skipped" ? handoff.submissionId : options.submissionId;
|
|
83
|
+
});
|
|
84
|
+
await options.conversation.append(steer.messageId, message, { submissionId: owner, ...(userMessage ? { userMessage } : {}) });
|
|
85
|
+
options.transaction(() => {
|
|
119
86
|
if (steer.harnessEntryId) options.messageUi.delete(steer.harnessEntryId);
|
|
120
|
-
options.conversation.append(steer.messageId, message, {
|
|
121
|
-
submissionId: handoff?.status === "aborted" || handoff?.status === "skipped"
|
|
122
|
-
? handoff.submissionId
|
|
123
|
-
: options.submissionId,
|
|
124
|
-
...(userMessage ? { userMessage } : {}),
|
|
125
|
-
});
|
|
126
87
|
options.steers.deleteByMessageId(steer.messageId);
|
|
127
88
|
});
|
|
128
89
|
continue;
|
|
@@ -130,8 +91,7 @@ export async function finishPendingSteers(options: {
|
|
|
130
91
|
if (steer.harnessEntryId) {
|
|
131
92
|
const cancelled = await options.cancelQueued(steer.harnessEntryId);
|
|
132
93
|
if (cancelled) {
|
|
133
|
-
if (
|
|
134
|
-
if (cancelled.value.kind === "already_consumed") {
|
|
94
|
+
if (cancelled.kind === "already_consumed") {
|
|
135
95
|
options.transaction(() => options.steers.deleteByMessageId(steer.messageId));
|
|
136
96
|
continue;
|
|
137
97
|
}
|
|
@@ -148,20 +108,16 @@ export async function finishPendingSteers(options: {
|
|
|
148
108
|
...(userMessage ? { userMessage } : {}),
|
|
149
109
|
});
|
|
150
110
|
options.waitUntil(submitted.completion);
|
|
151
|
-
options.transaction(() => {
|
|
152
|
-
let
|
|
111
|
+
const handoff = options.transaction(() => {
|
|
112
|
+
let current = options.submissions.find(submitted.receipt.submissionId) ?? submitted.receipt;
|
|
153
113
|
const abortReason = submissionAbortReason(options.submissions.find(options.submissionId));
|
|
154
|
-
if (abortReason &&
|
|
155
|
-
options.submissions.updateAbortReason(
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
if (handoff.status === "skipped" || handoff.status === "aborted") {
|
|
159
|
-
options.conversation.append(steer.messageId, message, {
|
|
160
|
-
submissionId: handoff.submissionId,
|
|
161
|
-
...(userMessage ? { userMessage } : {}),
|
|
162
|
-
});
|
|
114
|
+
if (abortReason && current.status === "pending" && current.requestId === requestId) {
|
|
115
|
+
options.submissions.updateAbortReason(current.submissionId, abortReason);
|
|
116
|
+
current = options.cancelHandoff(current.submissionId, abortReason);
|
|
163
117
|
}
|
|
164
|
-
|
|
118
|
+
return current;
|
|
165
119
|
});
|
|
120
|
+
if (handoff.status === "skipped" || handoff.status === "aborted") await options.conversation.append(steer.messageId, message, { submissionId: handoff.submissionId, ...(userMessage ? { userMessage } : {}) });
|
|
121
|
+
options.transaction(() => options.steers.deleteByMessageId(steer.messageId));
|
|
166
122
|
}
|
|
167
123
|
}
|
|
@@ -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
|
+
}
|
|
@@ -5,7 +5,7 @@ import { TelemetryRecorder } from "./recorder";
|
|
|
5
5
|
import { RuntimeTelemetry } from "./runtime-telemetry";
|
|
6
6
|
import type { AgentTelemetryBinding } from "./contract";
|
|
7
7
|
import type { PiGenerationLifecycleEvent } from "../models";
|
|
8
|
-
import type { TemporaryAgentLifecycleEvent } from "../tools/temporary
|
|
8
|
+
import type { TemporaryAgentLifecycleEvent } from "../tools/subagent/temporary/coordinator";
|
|
9
9
|
|
|
10
10
|
const TELEMETRY_COLD_BUFFER_LIMIT = 1_000;
|
|
11
11
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SessionHarness } from "../harness/session-harness";
|
|
2
|
+
import { projectSessionEntry, type ProjectedSessionEntry as Entry } from "../conversation/session-entry";
|
|
3
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
2
4
|
import type {
|
|
3
5
|
RuntimeLifecycleFact,
|
|
4
6
|
RuntimeModelUsageEvent,
|
|
@@ -7,7 +9,7 @@ import type {
|
|
|
7
9
|
RuntimeTurnEventsPort,
|
|
8
10
|
} from "../config/ports";
|
|
9
11
|
import type { StoredSubmission } from "../submission/repository";
|
|
10
|
-
import { decodeSubagentCompletion, isZeroSubagentUsage, subagentTerminalStatus, type RuntimeSubagentRunMetadata } from "../tools/subagent
|
|
12
|
+
import { decodeSubagentCompletion, isZeroSubagentUsage, subagentTerminalStatus, type RuntimeSubagentRunMetadata } from "../tools/subagent/runtime";
|
|
11
13
|
import type { RuntimeTelemetryCoordinator } from "./coordinator";
|
|
12
14
|
import { summarizeModelUsage } from "./model-usage";
|
|
13
15
|
import type { RuntimeEventOutboxRepository } from "./runtime-event-repository";
|
|
@@ -87,10 +89,22 @@ export class RuntimeTurnEvents {
|
|
|
87
89
|
} }, submission.completedAt ?? Date.now());
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
/** Pi summarizes through completeSimple; account only its successful, measured response. */
|
|
93
|
+
compactionGeneration(submissionId: string, event: import("../models").PiGenerationLifecycleEvent): void {
|
|
94
|
+
if (event.type !== "finished" || event.outcome !== "success" || !event.output || event.output.stopReason === "error" || event.output.stopReason === "aborted") return;
|
|
95
|
+
const response = event.output;
|
|
96
|
+
this.save({ type: "model-usage", event: {
|
|
97
|
+
eventId: `${submissionId}:compaction:${event.generationId}`, submissionId, runId: submissionId,
|
|
98
|
+
kind: "compaction", api: response.api, provider: response.provider, model: response.model,
|
|
99
|
+
...(response.responseModel ? { responseModel: response.responseModel } : {}), usage: response.usage,
|
|
100
|
+
} }, event.timestamp);
|
|
101
|
+
this.options.waitUntil(this.drain());
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
private recordEntry(submissionId: string, entry: Entry, model: RequestedModel): void {
|
|
91
105
|
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
92
106
|
this.save({ type: "model-usage", event: {
|
|
93
|
-
eventId: `${submissionId}:model:${entry.
|
|
107
|
+
eventId: `${submissionId}:model:${entry.identity}`,
|
|
94
108
|
submissionId,
|
|
95
109
|
runId: submissionId,
|
|
96
110
|
kind: "assistant",
|
|
@@ -107,7 +121,7 @@ export class RuntimeTurnEvents {
|
|
|
107
121
|
} else if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
108
122
|
if (entry.usage) {
|
|
109
123
|
this.save({ type: "model-usage", event: {
|
|
110
|
-
eventId: `${submissionId}:compaction:${entry.
|
|
124
|
+
eventId: `${submissionId}:compaction:${entry.identity}`,
|
|
111
125
|
submissionId,
|
|
112
126
|
runId: submissionId,
|
|
113
127
|
kind: "compaction",
|
|
@@ -131,28 +145,24 @@ export class RuntimeTurnEvents {
|
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
/** Live observation and recovery use exactly the same committed-entry projection. */
|
|
134
|
-
observe(
|
|
135
|
-
harness.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
toolCallId: event.toolCallId,
|
|
143
|
-
toolName: event.toolName,
|
|
144
|
-
input: event.args,
|
|
145
|
-
});
|
|
148
|
+
async observe(runtime: SessionHarness, submissionId: string, model: RequestedModel): Promise<void> {
|
|
149
|
+
runtime.harness.subscribeCommits((publication) => {
|
|
150
|
+
for (const change of publication.changes) {
|
|
151
|
+
if (change.type === "entry" && change.value.conversationId === runtime.conversation.id) {
|
|
152
|
+
this.recordEntry(submissionId, projectSessionEntry(change.value), model);
|
|
153
|
+
this.options.waitUntil(this.drain());
|
|
154
|
+
}
|
|
155
|
+
}
|
|
146
156
|
});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
toolCallId: event.toolCallId,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
157
|
+
const watch = await runtime.watch(BACKGROUND_CONTEXT);
|
|
158
|
+
watch.start(async (events) => {
|
|
159
|
+
for (const event of events) {
|
|
160
|
+
if (event.type === "tool_execution_start") this.options.telemetry.capture("toolStarted", { submissionId, toolCallId: event.toolCallId, toolName: event.toolName, input: event.args });
|
|
161
|
+
if (event.type === "tool_execution_end") {
|
|
162
|
+
const result = event.entry?.model?.[0];
|
|
163
|
+
this.options.telemetry.capture("toolFinished", { submissionId, toolCallId: event.toolCallId, toolName: event.toolName, outcome: result?.role === "toolResult" && !result.isError ? "completed" : "failed", output: result, outputBytes: JSON.stringify(result ?? {}).length });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
156
166
|
});
|
|
157
167
|
}
|
|
158
168
|
|