@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.
Files changed (125) hide show
  1. package/package.json +11 -9
  2. package/src/adapter/cloudflare/agent.ts +177 -122
  3. package/src/adapter/cloudflare/define-agent.ts +47 -26
  4. package/src/adapter/cloudflare/index.ts +18 -0
  5. package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
  6. package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
  7. package/src/adapter/cloudflare/schema.ts +15 -0
  8. package/src/adapter/cloudflare/subagent/definition.ts +25 -13
  9. package/src/adapter/cloudflare/subagent/runner.ts +43 -50
  10. package/src/adapter/cloudflare/subagent/tools.ts +4 -4
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  13. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  14. package/src/config/agent-context.ts +1 -1
  15. package/src/config/assembler.ts +48 -31
  16. package/src/config/assembly-snapshot.ts +1 -1
  17. package/src/config/definition.ts +1 -1
  18. package/src/config/harness-runtime.ts +51 -13
  19. package/src/config/model-guidance.ts +20 -0
  20. package/src/config/pinning.ts +39 -5
  21. package/src/config/ports.ts +57 -7
  22. package/src/config/public-contracts.ts +2 -2
  23. package/src/context/assembly.ts +1 -1
  24. package/src/context/extension/assembly.ts +2 -2
  25. package/src/context/memory/budget/gate.ts +19 -3
  26. package/src/conversation/harness-resume.ts +89 -238
  27. package/src/conversation/harness-stream.ts +87 -71
  28. package/src/conversation/history.ts +154 -152
  29. package/src/conversation/message/contract.ts +12 -0
  30. package/src/conversation/message/conversion.ts +3 -3
  31. package/src/conversation/message/projection.ts +61 -21
  32. package/src/conversation/session-entry.ts +29 -0
  33. package/src/conversation/state.ts +23 -2
  34. package/src/harness/governance.ts +206 -0
  35. package/src/harness/index.ts +3 -0
  36. package/src/harness/session-harness.ts +255 -446
  37. package/src/harness/submission-operation.ts +15 -40
  38. package/src/index.ts +18 -8
  39. package/src/lib/artifacts.ts +18 -0
  40. package/src/lib/execution-level.ts +13 -0
  41. package/src/lib/prompt.ts +9 -10
  42. package/src/lib/token-estimate.ts +8 -0
  43. package/src/model-protocol/openrouter.ts +41 -0
  44. package/src/models/index.ts +200 -35
  45. package/src/models/openrouter/messages.ts +13 -8
  46. package/src/models/openrouter/openai.ts +200 -0
  47. package/src/models/openrouter/protocol-response.ts +85 -0
  48. package/src/models/provider-streams.ts +45 -0
  49. package/src/session/cloudflare/migration.ts +382 -864
  50. package/src/session/cloudflare/storage.ts +13 -536
  51. package/src/session/index.ts +22 -35
  52. package/src/skills/springbrand-worker-website/index.ts +1 -1
  53. package/src/submission/admission.ts +12 -1
  54. package/src/submission/contracts.ts +42 -2
  55. package/src/submission/delivery.ts +1 -1
  56. package/src/submission/finalization.ts +36 -15
  57. package/src/submission/repository.ts +64 -25
  58. package/src/submission/steer-delivery.ts +32 -76
  59. package/src/submission/unattended.ts +40 -0
  60. package/src/telemetry/coordinator.ts +1 -1
  61. package/src/telemetry/subagent-lifecycle.ts +1 -1
  62. package/src/telemetry/turn-events.ts +35 -25
  63. package/src/tools/README.md +44 -0
  64. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  65. package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
  66. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
  67. package/src/tools/assistant/ask-user.ts +317 -0
  68. package/src/tools/assistant/candidate.ts +15 -0
  69. package/src/tools/assistant/followups.ts +34 -0
  70. package/src/tools/assistant/index.ts +19 -0
  71. package/src/tools/assistant/memory.ts +73 -0
  72. package/src/tools/assistant/plan.ts +98 -0
  73. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  74. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
  75. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  76. package/src/tools/codemode/workspace-connector.ts +198 -0
  77. package/src/tools/extension/index.ts +42 -0
  78. package/src/tools/index.ts +29 -26
  79. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  80. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  81. package/src/tools/runtime/activation.ts +40 -0
  82. package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
  83. package/src/tools/runtime/compiler.ts +246 -0
  84. package/src/tools/runtime/contracts.ts +140 -0
  85. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
  86. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  87. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  88. package/src/tools/runtime/result.ts +22 -0
  89. package/src/tools/runtime/unattended-error.ts +4 -0
  90. package/src/tools/sandbox/index.ts +228 -0
  91. package/src/tools/schedule/index.ts +301 -0
  92. package/src/tools/skill/contracts.ts +20 -0
  93. package/src/tools/skill/index.ts +99 -0
  94. package/src/tools/skill/resources.ts +322 -0
  95. package/src/tools/skill/scripts.ts +168 -0
  96. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  97. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  98. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  99. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  100. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  101. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
  102. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  103. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  104. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  105. package/src/tools/web/index.ts +2 -0
  106. package/src/tools/web/search/api.ts +1322 -0
  107. package/src/tools/web/search/index.ts +2 -0
  108. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  109. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  110. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  111. package/src/tools/base.ts +0 -534
  112. package/src/tools/compiler.ts +0 -327
  113. package/src/tools/schedule.ts +0 -261
  114. package/src/tools/skill.ts +0 -565
  115. package/src/tools/web-search/api.ts +0 -1318
  116. package/src/tools/web-search/index.ts +0 -2
  117. package/src/tools/workspace-state-connector.ts +0 -146
  118. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  119. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  120. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  121. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  122. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  123. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  124. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  125. /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 "@earendil-works/pi-agent-core";
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)) return initial;
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
- return this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
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
- return this.options.transaction(() => {
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
- if (effectiveOutcome === "aborted" && reason === USER_STOP_REASON) {
83
- if (completed.completedAt === null) {
84
- throw new Error(
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
- import type { LaneSnapshot } from "@earendil-works/pi-agent-core";
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
- admission_retryable
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 { CancelQueuedResult, LaneSnapshot } from "@earendil-works/pi-agent-core";
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(message: PiCanonicalUserInput): Promise<string>;
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 entryId = await this.options.enqueue(
24
- JSON.parse(steer.canonicalJson) as PiCanonicalUserInput,
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
- /** Called after Pi commits the original queued user entry. */
30
- settle(entryId: string): boolean {
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.persistSidecar(steer, entryId);
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
- async reconcile(
41
- submissionId: string,
42
- snapshot: Pick<LaneSnapshot, "queues" | "transcript">,
43
- ): Promise<void> {
44
- const pending = this.options.steers.listForSubmission(submissionId);
45
- const queuedIds = new Set(snapshot.queues.map(({ entryId }) => entryId));
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 (!cancelled.ok) throw cancelled.error;
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 handoff = options.submissions.find(submitted.receipt.submissionId) ?? submitted.receipt;
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 && handoff.status === "pending" && handoff.requestId === requestId) {
155
- options.submissions.updateAbortReason(handoff.submissionId, abortReason);
156
- handoff = options.cancelHandoff(handoff.submissionId, abortReason);
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
- options.steers.deleteByMessageId(steer.messageId);
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-agent/coordinator";
8
+ import type { TemporaryAgentLifecycleEvent } from "../tools/subagent/temporary/coordinator";
9
9
 
10
10
  const TELEMETRY_COLD_BUFFER_LIMIT = 1_000;
11
11
 
@@ -2,7 +2,7 @@ import type {
2
2
  RuntimeEventConfirmation,
3
3
  RuntimeLifecycleFact,
4
4
  RuntimeSubagentUsageEvent,
5
- } from "../tools/subagent-authority";
5
+ } from "../tools/subagent/authority";
6
6
 
7
7
  export interface RuntimeLifecycleSink {
8
8
  save(
@@ -1,4 +1,6 @@
1
- import type { AgentHarness, Entry } from "@earendil-works/pi-agent-core";
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-runtime";
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.id}`,
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.id}`,
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(harness: Pick<AgentHarness, "events">, submissionId: string, model: RequestedModel): void {
135
- harness.events.on("entry_added", ({ entry }) => {
136
- this.recordEntry(submissionId, entry, model);
137
- this.options.waitUntil(this.drain());
138
- });
139
- harness.events.on("tool_start", (event) => {
140
- this.options.telemetry.capture("toolStarted", {
141
- submissionId,
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
- harness.events.on("tool_end", (event) => {
148
- this.options.telemetry.capture("toolFinished", {
149
- submissionId,
150
- toolCallId: event.toolCallId,
151
- toolName: event.toolName,
152
- outcome: event.isError ? "failed" : "completed",
153
- output: event.result,
154
- outputBytes: JSON.stringify(event.result).length,
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