@springbrand/agent-runtime 0.2.0-alpha.72 → 0.3.0-alpha.1

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 (141) hide show
  1. package/package.json +3 -3
  2. package/src/{agent-tool-runtime.ts → adapter/cloudflare/agent-tool.ts} +1 -1
  3. package/src/adapter/cloudflare/agent.ts +1539 -0
  4. package/src/{db/index.ts → adapter/cloudflare/database.ts} +18 -55
  5. package/src/{runtime-agent.ts → adapter/cloudflare/define-agent.ts} +25 -35
  6. package/src/adapter/cloudflare/dynamic-worker-observability.ts +1 -1
  7. package/src/adapter/cloudflare/resources/runtime-resources.ts +4 -4
  8. package/src/adapter/cloudflare/sandbox/adapter.ts +1 -1
  9. package/src/{db → adapter/cloudflare}/schema.ts +16 -83
  10. package/src/adapter/cloudflare/subagent/definition.ts +63 -33
  11. package/src/adapter/cloudflare/subagent/runner.ts +174 -120
  12. package/src/adapter/cloudflare/subagent/tools.ts +13 -14
  13. package/src/adapter/cloudflare/universal-agent/preparation.ts +7 -7
  14. package/src/adapter/cloudflare/universal-agent/tools.ts +10 -10
  15. package/src/adapter/cloudflare/workspace/git-fs.ts +1 -1
  16. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +1 -1
  17. package/src/adapter/cloudflare/workspace/version-control.ts +2 -2
  18. package/src/config/activation.ts +140 -0
  19. package/src/{runtime-agent-context.ts → config/agent-context.ts} +3 -3
  20. package/src/{runtime-assembler.ts → config/assembler.ts} +20 -19
  21. package/src/{kernel/runtime-assembly.ts → config/assembly-projection.ts} +4 -4
  22. package/src/{pi/assembly/snapshot.ts → config/assembly-snapshot.ts} +6 -6
  23. package/src/{runtime-definition.ts → config/definition.ts} +13 -13
  24. package/src/config/harness-runtime.ts +182 -0
  25. package/src/config/index.ts +14 -0
  26. package/src/{pi/runtime-adapter/assembly.ts → config/pinning.ts} +38 -16
  27. package/src/{kernel/bindings.ts → config/ports.ts} +9 -13
  28. package/src/{kernel → config}/profile.ts +7 -0
  29. package/src/{kernel → config}/public-contracts.ts +6 -6
  30. package/src/{kernel → config}/runtime-load.ts +1 -1
  31. package/src/{pi/assembly/context.ts → context/assembly.ts} +4 -206
  32. package/src/{pi/assembly/extensions.ts → context/extension/assembly.ts} +5 -5
  33. package/src/context/extension/index.ts +3 -0
  34. package/src/context/index.ts +6 -0
  35. package/src/{layers/context → context/memory}/budget/gate.ts +2 -2
  36. package/src/conversation/harness-resume.ts +288 -0
  37. package/src/conversation/harness-stream.ts +98 -0
  38. package/src/{pi/runtime-adapter/transcript.ts → conversation/history.ts} +206 -233
  39. package/src/conversation/index.ts +6 -0
  40. package/src/{pi → conversation}/message/contract.ts +3 -0
  41. package/src/{pi → conversation}/message/conversion.ts +75 -0
  42. package/src/{pi → conversation}/message/index.ts +1 -0
  43. package/src/{pi → conversation}/message/model-input.ts +1 -1
  44. package/src/{pi → conversation}/message/projection.ts +76 -10
  45. package/src/{db/message-ui.repo.ts → conversation/repository.ts} +4 -0
  46. package/src/conversation/state.ts +175 -0
  47. package/src/harness/index.ts +7 -0
  48. package/src/harness/session-harness.ts +481 -0
  49. package/src/harness/submission-operation.ts +49 -0
  50. package/src/index.ts +38 -43
  51. package/src/lib/mcp.ts +1 -1
  52. package/src/{pi/runtime-adapter/models.ts → models/index.ts} +64 -18
  53. package/src/{pi/turn/tool-recovery.ts → session/cloudflare/legacy-tool-recovery.ts} +33 -525
  54. package/src/{pi/session → session/cloudflare}/migration.ts +334 -8
  55. package/src/{pi/session → session/cloudflare}/storage.ts +8 -36
  56. package/src/session/index.ts +39 -0
  57. package/src/submission/admission.ts +275 -0
  58. package/src/{kernel/receipts.ts → submission/contracts.ts} +14 -0
  59. package/src/submission/delivery.ts +212 -0
  60. package/src/submission/finalization.ts +111 -0
  61. package/src/submission/index.ts +9 -0
  62. package/src/{kernel/submission-lifecycle.ts → submission/lifecycle.ts} +41 -28
  63. package/src/{db/submission.repo.ts → submission/repository.ts} +61 -118
  64. package/src/submission/steer-delivery.ts +167 -0
  65. package/src/{db/steer.repo.ts → submission/steer-repository.ts} +27 -5
  66. package/src/telemetry/contract.ts +12 -0
  67. package/src/telemetry/coordinator.ts +65 -0
  68. package/src/telemetry/delivery.ts +2 -2
  69. package/src/telemetry/index.ts +5 -0
  70. package/src/{model-usage.ts → telemetry/model-usage.ts} +1 -1
  71. package/src/telemetry/recorder.ts +1 -1
  72. package/src/telemetry/runtime-telemetry.ts +12 -0
  73. package/src/{kernel/durable-lifecycle.ts → telemetry/subagent-lifecycle.ts} +1 -1
  74. package/src/telemetry/turn-events.ts +279 -0
  75. package/src/{pi/tool → tools}/ai-adapter.ts +2 -2
  76. package/src/tools/approval/index.ts +1 -0
  77. package/src/{db/approval.repo.ts → tools/approval/repository.ts} +10 -30
  78. package/src/{pi/tool → tools}/base.ts +14 -30
  79. package/src/tools/compiler.ts +327 -0
  80. package/src/{pi/tool → tools}/core-host.ts +3 -3
  81. package/src/{pi/tool → tools}/core.ts +3 -3
  82. package/src/tools/decision-gate.ts +227 -0
  83. package/src/{pi/tool → tools}/gateway.ts +1 -1
  84. package/src/tools/human-decisions.ts +127 -0
  85. package/src/tools/index.ts +30 -0
  86. package/src/tools/interaction/index.ts +1 -0
  87. package/src/{pi/tool → tools}/mcp.ts +1 -1
  88. package/src/{pi/tool → tools}/schedule.ts +3 -3
  89. package/src/{pi/tool → tools}/skill.ts +4 -4
  90. package/src/{kernel → tools}/subagent-runtime.ts +1 -1
  91. package/src/{pi/tool → tools}/subagent.ts +4 -4
  92. package/src/tools/temporary-agent/approvals.ts +126 -0
  93. package/src/{layers/orchestration/temporary-agent/core.ts → tools/temporary-agent/coordinator.ts} +41 -49
  94. package/src/tools/temporary-agent/core.ts +53 -0
  95. package/src/{layers/orchestration → tools}/temporary-agent/runner.ts +1 -1
  96. package/src/{layers/orchestration → tools}/temporary-agent/workspace.ts +1 -1
  97. package/src/{pi/tool → tools}/web-search/api.ts +1 -1
  98. package/src/{pi/tool → tools}/workspace-revision.ts +2 -2
  99. package/src/{pi/tool → tools}/workspace-sandbox.ts +2 -2
  100. package/src/{pi/tool → tools}/workspace-state-connector.ts +1 -1
  101. package/src/db/milestone.repo.ts +0 -104
  102. package/src/db/settlement.repo.ts +0 -104
  103. package/src/kernel/approval-lifecycle.ts +0 -638
  104. package/src/kernel/interaction-lifecycle.ts +0 -433
  105. package/src/kernel/recoverable-chat-agent.ts +0 -1020
  106. package/src/kernel/state.ts +0 -91
  107. package/src/pi/assembly/index.ts +0 -19
  108. package/src/pi/runtime-adapter/execution.ts +0 -1163
  109. package/src/pi/runtime-adapter/index.ts +0 -293
  110. package/src/pi/runtime-adapter/recovery.ts +0 -982
  111. package/src/pi/session/index.ts +0 -24
  112. package/src/pi/tool/compiler.ts +0 -483
  113. package/src/pi/tool/index.ts +0 -38
  114. package/src/pi/turn/approval.ts +0 -181
  115. package/src/pi/turn/index.ts +0 -82
  116. package/src/pi/turn/interaction.ts +0 -181
  117. package/src/runtime.ts +0 -4760
  118. /package/src/{kernel/runtime-assembly-view.ts → config/assembly-view.ts} +0 -0
  119. /package/src/{kernel → config}/degradation.ts +0 -0
  120. /package/src/{kernel → config}/runtime-config.ts +0 -0
  121. /package/src/{kernel/extensions.ts → context/extension/contracts.ts} +0 -0
  122. /package/src/{db/ext-context.repo.ts → context/extension/repository.ts} +0 -0
  123. /package/src/{pi/runtime-adapter/openrouter-messages.ts → models/openrouter/messages.ts} +0 -0
  124. /package/src/{pi/runtime-adapter → models/openrouter}/test-fixtures/openrouter-tool-search.sse +0 -0
  125. /package/src/{db/submission-admission.repo.ts → submission/admission-repository.ts} +0 -0
  126. /package/src/{db/telemetry-outbox.repo.ts → telemetry/repository.ts} +0 -0
  127. /package/src/{db/runtime-event-outbox.repo.ts → telemetry/runtime-event-repository.ts} +0 -0
  128. /package/src/{pi/tool → tools}/declared.ts +0 -0
  129. /package/src/{db/interaction.repo.ts → tools/interaction/repository.ts} +0 -0
  130. /package/src/{pi/tool → tools}/nested-tools.ts +0 -0
  131. /package/src/{kernel/submission-authority.ts → tools/subagent-authority.ts} +0 -0
  132. /package/src/{db/agent-tool.repo.ts → tools/subagent-run-repository.ts} +0 -0
  133. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/contract.ts +0 -0
  134. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/extract/index.ts +0 -0
  135. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/fanout/index.ts +0 -0
  136. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/registry.ts +0 -0
  137. /package/src/{pi/tool → tools}/time.ts +0 -0
  138. /package/src/{pi/tool → tools}/web-fetch.ts +0 -0
  139. /package/src/{pi/tool → tools}/web-search/index.ts +0 -0
  140. /package/src/{pi/tool → tools}/web-search/web-search.ts +0 -0
  141. /package/src/{workspace-versioning.ts → tools/workspace-versioning.ts} +0 -0
@@ -0,0 +1,275 @@
1
+ import type { UIMessage } from "ai";
2
+ import { validateRuntimeAssemblyPin, type RuntimeAssemblyPin } from "../config/pinning";
3
+ import type {
4
+ RuntimeSubmissionAdmissionHook,
5
+ RuntimeSubmissionAdmissionResult,
6
+ } from "../config/ports";
7
+ import type { PiCanonicalUserInput } from "../conversation/message/contract";
8
+ import type { SubmissionAdmissionRepository } from "./admission-repository";
9
+ import type { SteerRepository } from "./steer-repository";
10
+ import {
11
+ MAX_PENDING_SUBMISSIONS,
12
+ SubmissionQueueFullError,
13
+ type SubmissionInput,
14
+ } from "./lifecycle";
15
+ import { isTerminalSubmissionStatus, submissionAbortReason, type StoredSubmission, type SubmissionRepository } from "./repository";
16
+
17
+ // Pending records retain identity indefinitely; only recent attempts reserve capacity.
18
+ const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
19
+
20
+ export interface SubmitMessageOptions {
21
+ requestId: string;
22
+ idempotencyKey?: string;
23
+ userMessageId?: string;
24
+ userMessage?: UIMessage & { role: "user" };
25
+ regenerate?: boolean;
26
+ /** Internal transfer of a durable accepted steer; never a public message option. */
27
+ steerHandoff?: { submissionId: string; steerId: string };
28
+ }
29
+
30
+ interface SubmissionAdmissionOptions {
31
+ admissions: SubmissionAdmissionRepository;
32
+ submissions: SubmissionRepository;
33
+ steers: SteerRepository;
34
+ transaction<T>(run: () => T): T;
35
+ prepareRuntime(): Promise<{
36
+ runtimeKey: string;
37
+ admission?: RuntimeSubmissionAdmissionHook;
38
+ }>;
39
+ findUserMessage(messageId: string): Promise<PiCanonicalUserInput | undefined>;
40
+ /** Called inside the admission transaction, after the Submission is inserted. */
41
+ onAdmitted(submission: StoredSubmission, hostAccepted: boolean): void;
42
+ onRejected(submission: StoredSubmission): void;
43
+ cancelHandoff(submissionId: string, reason: string): StoredSubmission;
44
+ }
45
+
46
+ function validateAdmissionResult(
47
+ result: RuntimeSubmissionAdmissionResult,
48
+ ): RuntimeSubmissionAdmissionResult {
49
+ if (!result || typeof result !== "object" || typeof result.accepted !== "boolean") {
50
+ throw new Error("Runtime admission result is invalid");
51
+ }
52
+ if (!result.accepted && (
53
+ typeof result.code !== "string" || !result.code.trim() ||
54
+ (result.retryable !== undefined && typeof result.retryable !== "boolean")
55
+ )) {
56
+ throw new Error("Runtime admission rejection is invalid");
57
+ }
58
+ return result;
59
+ }
60
+
61
+ function userContentKey(message: PiCanonicalUserInput): string {
62
+ return JSON.stringify(
63
+ typeof message.content === "string"
64
+ ? [{ type: "text", text: message.content }]
65
+ : message.content,
66
+ );
67
+ }
68
+
69
+ /** Owns Host admission identity, in-flight coalescing and atomic Submission creation. */
70
+ export class SubmissionAdmission {
71
+ private readonly inFlight = new Map<
72
+ string,
73
+ Promise<RuntimeSubmissionAdmissionResult>
74
+ >();
75
+
76
+ constructor(private readonly options: SubmissionAdmissionOptions) {}
77
+
78
+ input(
79
+ userMessage: PiCanonicalUserInput,
80
+ options: SubmitMessageOptions,
81
+ ): SubmissionInput<StoredSubmission> {
82
+ if (options.steerHandoff) {
83
+ const { submissionId, steerId } = options.steerHandoff;
84
+ const steer = options.userMessageId
85
+ ? this.options.steers.findByMessageId(options.userMessageId)
86
+ : null;
87
+ const identity = `${submissionId}:steer:${steerId}`;
88
+ if (!steer || steer.submissionId !== submissionId || steer.steerId !== steerId ||
89
+ options.requestId !== identity || options.idempotencyKey !== identity ||
90
+ userContentKey(JSON.parse(steer.canonicalJson) as PiCanonicalUserInput) !== userContentKey(userMessage)) {
91
+ throw new Error("Steer handoff must reference an accepted message");
92
+ }
93
+ }
94
+ return {
95
+ requestId: options.requestId,
96
+ ...(options.steerHandoff ? { isSteerHandoff: true as const } : {}),
97
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
98
+ prepareAdmission: async () => {
99
+ const runtime = await this.options.prepareRuntime();
100
+ if (options.regenerate) {
101
+ const original = options.userMessageId
102
+ ? await this.options.findUserMessage(options.userMessageId)
103
+ : undefined;
104
+ if (!original || userContentKey(original) !== userContentKey(userMessage)) {
105
+ throw new Error("Regenerate request must match an existing user message");
106
+ }
107
+ }
108
+ const identity = runtime.admission
109
+ ? this.reserve(options)
110
+ : undefined;
111
+ const runId = identity?.runId ?? crypto.randomUUID();
112
+ const requestId = identity?.requestId ?? options.requestId;
113
+ const idempotencyKey = identity
114
+ ? identity.idempotencyKey ?? undefined
115
+ : options.idempotencyKey;
116
+ const key = idempotencyKey
117
+ ? `idempotency:${idempotencyKey}`
118
+ : `request:${requestId}`;
119
+ let admission: RuntimeSubmissionAdmissionResult | undefined;
120
+ if (identity?.status === "accepted") {
121
+ admission = { accepted: true };
122
+ } else if (identity?.status === "rejected") {
123
+ admission = {
124
+ accepted: false,
125
+ code: identity.code ?? "admission_rejected",
126
+ retryable: identity.retryable ?? false,
127
+ };
128
+ } else if (runtime.admission) {
129
+ let pending = this.inFlight.get(key);
130
+ if (!pending) {
131
+ pending = runtime.admission({
132
+ requestId,
133
+ ...(idempotencyKey ? { idempotencyKey } : {}),
134
+ runId,
135
+ }).then(validateAdmissionResult).catch((error) => {
136
+ this.inFlight.delete(key);
137
+ throw error;
138
+ });
139
+ this.inFlight.set(key, pending);
140
+ }
141
+ admission = await pending;
142
+ }
143
+ return () => {
144
+ if (admission && !admission.accepted) {
145
+ this.options.admissions.saveRejected(
146
+ requestId,
147
+ runId,
148
+ admission.code,
149
+ admission.retryable ?? false,
150
+ );
151
+ const rejected = this.skip({
152
+ submissionId: runId,
153
+ requestId,
154
+ idempotencyKey: idempotencyKey ?? null,
155
+ createdAt: userMessage.timestamp,
156
+ code: admission.code,
157
+ retryable: admission.retryable ?? false,
158
+ userMessageId: options.userMessageId,
159
+ assistantMessageId: crypto.randomUUID(),
160
+ });
161
+ this.inFlight.delete(key);
162
+ return { ...rejected, admissionRejected: true };
163
+ }
164
+ if (admission?.accepted) {
165
+ this.options.admissions.saveAccepted(requestId, runId);
166
+ }
167
+ this.options.submissions.insert({
168
+ submissionId: runId,
169
+ requestId,
170
+ idempotencyKey: idempotencyKey ?? null,
171
+ createdAt: userMessage.timestamp,
172
+ assemblyRevision: "",
173
+ assemblyDescriptor: "",
174
+ runtimeKey: runtime.runtimeKey,
175
+ assistantMessageId: crypto.randomUUID(),
176
+ queuedInputJson: JSON.stringify(userMessage),
177
+ queuedUiMessageJson: options.userMessage
178
+ ? JSON.stringify(options.userMessage)
179
+ : null,
180
+ userMessageId: options.userMessageId ?? crypto.randomUUID(),
181
+ regenerateMessageId: options.regenerate
182
+ ? options.userMessageId ?? null
183
+ : null,
184
+ runId,
185
+ });
186
+ const admitted = this.options.submissions.find(runId)!;
187
+ this.options.onAdmitted(admitted, admission?.accepted === true);
188
+ this.inFlight.delete(key);
189
+ const abortReason = options.steerHandoff
190
+ ? submissionAbortReason(this.options.submissions.find(options.steerHandoff.submissionId))
191
+ : undefined;
192
+ if (abortReason) {
193
+ this.options.submissions.updateAbortReason(runId, abortReason);
194
+ return this.options.cancelHandoff(runId, abortReason);
195
+ }
196
+ return admitted;
197
+ };
198
+ },
199
+ };
200
+ }
201
+
202
+ /** Validate before loading capabilities, then reuse or atomically persist the first-run pin. */
203
+ async pin(
204
+ submission: StoredSubmission,
205
+ preparePin: () => Promise<RuntimeAssemblyPin>,
206
+ restoreRuntime?: (runtimeKey: string) => Promise<void>,
207
+ ): Promise<StoredSubmission> {
208
+ const current = this.options.submissions.find(submission.submissionId);
209
+ if (!current) throw new Error(`Unknown SpringBrand submission: ${submission.submissionId}`);
210
+ const hasPin = await validateRuntimeAssemblyPin(current.runtimeKey, {
211
+ revision: current.assemblyRevision,
212
+ descriptor: current.assemblyDescriptor,
213
+ });
214
+ await restoreRuntime?.(current.runtimeKey);
215
+ const restored = this.options.submissions.find(current.submissionId);
216
+ if (!restored || isTerminalSubmissionStatus(restored.status)) {
217
+ throw new Error("Submission ended before Runtime assembly was pinned");
218
+ }
219
+ if (hasPin) return restored;
220
+ const pin = await preparePin();
221
+ if (!await validateRuntimeAssemblyPin(current.runtimeKey, pin)) {
222
+ throw new Error("Submission assembly pin is incomplete");
223
+ }
224
+ this.options.submissions.pinAssembly(current.submissionId, pin.revision, pin.descriptor);
225
+ const pinned = this.options.submissions.find(current.submissionId);
226
+ if (!pinned?.assemblyRevision || !pinned.assemblyDescriptor || isTerminalSubmissionStatus(pinned.status)) {
227
+ throw new Error("Submission ended before Runtime assembly was pinned");
228
+ }
229
+ await validateRuntimeAssemblyPin(pinned.runtimeKey, {
230
+ revision: pinned.assemblyRevision,
231
+ descriptor: pinned.assemblyDescriptor,
232
+ });
233
+ return pinned;
234
+ }
235
+
236
+ /** The caller shares this synchronous write with its admission/timeout transaction. */
237
+ skip(input: Parameters<SubmissionRepository["insertRejected"]>[0]): StoredSubmission {
238
+ this.options.submissions.insertRejected(input);
239
+ const skipped = this.options.submissions.find(input.submissionId)!;
240
+ this.options.onRejected(skipped);
241
+ return skipped;
242
+ }
243
+
244
+ private reserve(options: SubmitMessageOptions) {
245
+ const attemptedAt = Date.now();
246
+ return this.options.transaction(() => {
247
+ const existing = this.options.admissions.find(
248
+ options.requestId,
249
+ options.idempotencyKey,
250
+ );
251
+ const attemptedSince = attemptedAt - ADMISSION_ATTEMPT_WINDOW_MS;
252
+ const needsCapacity = !existing ||
253
+ (existing.status === "pending" && existing.attemptedAt < attemptedSince);
254
+ if (needsCapacity && !options.steerHandoff &&
255
+ this.options.submissions.countPending() +
256
+ this.options.admissions.countPending(attemptedSince) >=
257
+ MAX_PENDING_SUBMISSIONS
258
+ ) {
259
+ throw new SubmissionQueueFullError();
260
+ }
261
+ if (existing) {
262
+ if (existing.status === "pending") {
263
+ this.options.admissions.recordAttempt(existing.requestId, attemptedAt);
264
+ }
265
+ return existing;
266
+ }
267
+ return this.options.admissions.begin(
268
+ options.requestId,
269
+ options.idempotencyKey ?? null,
270
+ crypto.randomUUID(),
271
+ attemptedAt,
272
+ );
273
+ });
274
+ }
275
+ }
@@ -1,5 +1,6 @@
1
1
  import type { UIMessage } from "ai";
2
2
  import type { ExecutionLevel } from "../lib/execution-level";
3
+ import type { StoredSubmission } from "./repository";
3
4
 
4
5
  export type ApprovalDecision =
5
6
  | { decision: "deny"; reason?: string }
@@ -93,6 +94,19 @@ export interface SubmissionReceipt {
93
94
  completedAt?: number;
94
95
  }
95
96
 
97
+ /** Public receipts expose lifecycle facts without queued input or runtime configuration. */
98
+ export function projectSubmissionReceipt(submission: StoredSubmission): SubmissionReceipt {
99
+ return {
100
+ submissionId: submission.submissionId,
101
+ status: submission.status,
102
+ accepted: submission.accepted,
103
+ createdAt: submission.createdAt,
104
+ ...(submission.error !== null ? { error: submission.error } : {}),
105
+ ...(submission.retryable !== undefined ? { retryable: submission.retryable } : {}),
106
+ ...(submission.completedAt !== null ? { completedAt: submission.completedAt } : {}),
107
+ };
108
+ }
109
+
96
110
  /** Shared discriminator keeps the Stop control and durable transcript semantics aligned. */
97
111
  export const USER_STOP_REASON = "Stopped by user";
98
112
 
@@ -0,0 +1,212 @@
1
+ import type { UIMessage } from "ai";
2
+ import type { PiCanonicalUserInput } from "../conversation/message/contract";
3
+ import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
4
+ import type { RuntimeTurnEvents } from "../telemetry/turn-events";
5
+ import type { HarnessHumanDecisions } from "../tools/human-decisions";
6
+ import type { SubmitMessageOptions } from "./admission";
7
+ import {
8
+ projectSubmissionReceipt,
9
+ type MessageDelivery,
10
+ type MessageDispatchReceipt,
11
+ } from "./contracts";
12
+ import {
13
+ SubmissionQueueFullError,
14
+ type SubmissionHandle,
15
+ type SubmissionLifecycle,
16
+ } from "./lifecycle";
17
+ import type { StoredSubmission, SubmissionRepository } from "./repository";
18
+ import type { PendingSteer, SteerRepository } from "./steer-repository";
19
+
20
+ interface ActiveSteerTarget {
21
+ submissionId: string;
22
+ agent: {
23
+ steer(message: PiCanonicalUserInput, messageId: string): Promise<void>;
24
+ };
25
+ }
26
+
27
+ interface SubmissionDeliveryOptions {
28
+ submissions: SubmissionRepository;
29
+ steers: SteerRepository;
30
+ transaction<T>(run: () => T): T;
31
+ lifecycle: Pick<
32
+ SubmissionLifecycle<StoredSubmission, ActiveSteerTarget>,
33
+ "currentActive" | "continueAfterCurrent"
34
+ >;
35
+ decisions: Pick<
36
+ HarnessHumanDecisions,
37
+ "isWaitingForApproval" | "isWaitingForInteraction" | "cancelInteractions"
38
+ >;
39
+ telemetry: Pick<RuntimeTelemetryCoordinator, "capture">;
40
+ events: Pick<RuntimeTurnEvents, "submissionTerminal" | "drain">;
41
+ normalize(message: UIMessage & { role: "user" }): Promise<PiCanonicalUserInput>;
42
+ submit(
43
+ message: PiCanonicalUserInput,
44
+ options: SubmitMessageOptions,
45
+ ): Promise<SubmissionHandle<StoredSubmission>>;
46
+ onChanged(): Promise<void>;
47
+ waitUntil(promise: Promise<unknown>): void;
48
+ }
49
+
50
+ /** Chooses FIFO submission or durable steering before waking an execution. */
51
+ export class SubmissionDelivery {
52
+ constructor(private readonly options: SubmissionDeliveryOptions) {}
53
+
54
+ async dispatch(message: UIMessage, delivery: MessageDelivery): Promise<MessageDispatchReceipt> {
55
+ if (message.role !== "user" || !message.id || !Array.isArray(message.parts) ||
56
+ (delivery !== "steer" && delivery !== "enqueue")) {
57
+ return this.reject("invalid_message", "A valid user message is required");
58
+ }
59
+ let canonical: PiCanonicalUserInput;
60
+ try {
61
+ canonical = await this.options.normalize(message as UIMessage & { role: "user" });
62
+ } catch (error) {
63
+ return this.reject("invalid_message", error instanceof Error ? error.message : String(error));
64
+ }
65
+ const running = this.options.submissions.findRunning();
66
+ const parked = running && this.options.decisions.isWaitingForInteraction(running.submissionId)
67
+ ? running
68
+ : undefined;
69
+ const active = this.options.lifecycle.currentActive();
70
+ // Enqueue cannot unblock a Turn waiting for an answer; the new message supersedes it.
71
+ if (parked || delivery === "steer") {
72
+ const target = parked ?? active ?? running ?? this.options.submissions.findNextPending();
73
+ if (target) {
74
+ if (this.options.decisions.isWaitingForApproval(target.submissionId)) {
75
+ return this.reject("not_steerable", "The current Turn is waiting for approval");
76
+ }
77
+ const inserted = this.options.transaction(() => this.insertSteer({
78
+ steerId: crypto.randomUUID(),
79
+ submissionId: target.submissionId,
80
+ messageId: message.id,
81
+ harnessEntryId: null,
82
+ canonicalJson: JSON.stringify(canonical),
83
+ uiMessageJson: JSON.stringify(message),
84
+ createdAt: canonical.timestamp,
85
+ }, canonical));
86
+ if (inserted) {
87
+ await this.deliverActive(active, target.submissionId, canonical, message.id);
88
+ await this.options.onChanged();
89
+ }
90
+ this.resumeInteraction(target.submissionId);
91
+ return { kind: "accepted", submissionId: target.submissionId, messageId: message.id };
92
+ }
93
+ }
94
+
95
+ try {
96
+ const submitted = await this.options.submit(canonical, {
97
+ requestId: message.id,
98
+ idempotencyKey: message.id,
99
+ userMessageId: message.id,
100
+ userMessage: message as UIMessage & { role: "user" },
101
+ });
102
+ this.options.waitUntil(submitted.completion);
103
+ const position = this.options.submissions.listPending()
104
+ .findIndex(({ submissionId }) => submissionId === submitted.receipt.submissionId) + 1;
105
+ return position > 0
106
+ ? { kind: "queued", submission: projectSubmissionReceipt(submitted.receipt), position }
107
+ : { kind: "accepted", submissionId: submitted.receipt.submissionId, messageId: message.id };
108
+ } catch (error) {
109
+ if (error instanceof SubmissionQueueFullError) return this.reject("queue_full", error.message);
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ async steerQueued(submissionId: string): Promise<MessageDispatchReceipt> {
115
+ const active = this.options.lifecycle.currentActive();
116
+ const target = active ?? this.options.submissions.findRunning() ??
117
+ this.options.submissions.findNextPending();
118
+ if (!target || this.options.decisions.isWaitingForApproval(target.submissionId)) {
119
+ return this.reject("not_steerable", "The current Turn is not accepting steering");
120
+ }
121
+ const queued = this.options.submissions.find(submissionId);
122
+ if (!queued || queued.submissionId === target.submissionId || queued.status !== "pending" ||
123
+ !queued.queuedInputJson || !queued.userMessageId) {
124
+ return this.reject("invalid_message", "The queued message is no longer available");
125
+ }
126
+ let canonical: PiCanonicalUserInput;
127
+ let uiMessage: UIMessage | undefined;
128
+ try {
129
+ canonical = JSON.parse(queued.queuedInputJson) as PiCanonicalUserInput;
130
+ uiMessage = queued.queuedUiMessageJson ? JSON.parse(queued.queuedUiMessageJson) as UIMessage : undefined;
131
+ if (canonical?.role !== "user" ||
132
+ (uiMessage && (uiMessage.role !== "user" || uiMessage.id !== queued.userMessageId))) {
133
+ return this.reject("invalid_message", "The queued message is invalid");
134
+ }
135
+ } catch {
136
+ return this.reject("invalid_message", "The queued message is invalid");
137
+ }
138
+ const messageId = queued.userMessageId;
139
+ const moved = this.options.transaction(() => {
140
+ const latest = this.options.submissions.find(submissionId);
141
+ const current = this.options.submissions.findRunning() ?? this.options.submissions.findNextPending();
142
+ if (latest?.status !== "pending" || current?.submissionId !== target.submissionId) return false;
143
+ if (!this.insertSteer({
144
+ steerId: crypto.randomUUID(),
145
+ submissionId: target.submissionId,
146
+ messageId,
147
+ harnessEntryId: null,
148
+ canonicalJson: queued.queuedInputJson!,
149
+ uiMessageJson: queued.queuedUiMessageJson,
150
+ createdAt: queued.createdAt,
151
+ }, canonical)) return false;
152
+ const reason = "Moved to active Turn";
153
+ this.options.submissions.updateAbortReason(submissionId, reason);
154
+ this.options.submissions.updateTerminal(submissionId, "aborted", reason, Date.now());
155
+ this.options.events.submissionTerminal(this.options.submissions.find(submissionId)!);
156
+ return true;
157
+ });
158
+ if (!moved) return this.reject("invalid_message", "The queued message is no longer available");
159
+ try {
160
+ await this.deliverActive(active, target.submissionId, canonical, messageId);
161
+ this.resumeInteraction(target.submissionId);
162
+ } finally {
163
+ await this.options.events.drain();
164
+ await this.options.onChanged();
165
+ }
166
+ return {
167
+ kind: "steered",
168
+ submissionId: target.submissionId,
169
+ messageId,
170
+ ...(uiMessage ? { message: uiMessage } : {}),
171
+ };
172
+ }
173
+
174
+ private insertSteer(steer: PendingSteer, message: PiCanonicalUserInput): boolean {
175
+ if (this.options.steers.findByMessageId(steer.messageId)) return false;
176
+ this.options.steers.insert(steer);
177
+ this.options.telemetry.capture("turnSteered", {
178
+ submissionId: steer.submissionId,
179
+ steerId: steer.steerId,
180
+ messageId: steer.messageId,
181
+ content: message,
182
+ occurredAt: steer.createdAt,
183
+ });
184
+ return true;
185
+ }
186
+
187
+ private async deliverActive(
188
+ active: ActiveSteerTarget | undefined,
189
+ submissionId: string,
190
+ message: PiCanonicalUserInput,
191
+ messageId: string,
192
+ ): Promise<void> {
193
+ if (active?.submissionId === submissionId) {
194
+ // Receipt persistence is authoritative; a failed live delivery is retried on recovery.
195
+ await active.agent.steer(message, messageId).catch(() => undefined);
196
+ }
197
+ }
198
+
199
+ private resumeInteraction(submissionId: string): void {
200
+ if (!this.options.decisions.isWaitingForInteraction(submissionId)) return;
201
+ // The steer must be durable before cancelling the Tool that wakes the original Turn.
202
+ this.options.decisions.cancelInteractions(submissionId, "Superseded by a new message");
203
+ this.options.waitUntil(this.options.lifecycle.continueAfterCurrent(submissionId));
204
+ }
205
+
206
+ private reject(
207
+ code: "invalid_message" | "not_steerable" | "queue_full",
208
+ message: string,
209
+ ): MessageDispatchReceipt {
210
+ return { kind: "rejected", code, message };
211
+ }
212
+ }
@@ -0,0 +1,111 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type { CancelQueuedResult } from "@earendil-works/pi-agent-core";
3
+ import type { ConversationHistory } from "../conversation/history";
4
+ import type { MessageUiRepository } from "../conversation/repository";
5
+ import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
6
+ import type { RuntimeTurnEvents } from "../telemetry/turn-events";
7
+ import type { HarnessHumanDecisions } from "../tools/human-decisions";
8
+ import { USER_STOP_REASON } from "./contracts";
9
+ import type { SubmissionOutcome } from "./lifecycle";
10
+ import { isTerminalSubmissionStatus, type StoredSubmission, type SubmissionRepository } from "./repository";
11
+ import { finishPendingSteers } from "./steer-delivery";
12
+ import type { SteerRepository } from "./steer-repository";
13
+
14
+ /** Commits the Submission outcome and human/Host facts after accepted steering is preserved. */
15
+ export class SubmissionFinalizer {
16
+ constructor(private readonly options: {
17
+ submissions: SubmissionRepository;
18
+ steers: SteerRepository;
19
+ messageUi: MessageUiRepository;
20
+ transaction<T>(run: () => T): T;
21
+ conversation: Pick<ConversationHistory, "append" | "appendTurnAbortedMarker" | "storedMessages">;
22
+ decisions: Pick<HarnessHumanDecisions, "cancelForSubmission">;
23
+ events: Pick<RuntimeTurnEvents, "submissionTerminal">;
24
+ telemetry: Pick<RuntimeTelemetryCoordinator, "capture">;
25
+ releaseAbortedHarness(): Promise<void>;
26
+ cancelQueued(entryId: string): Promise<CancelQueuedResult | undefined>;
27
+ submit: Parameters<typeof finishPendingSteers>[0]["submit"];
28
+ waitUntil(promise: Promise<unknown>): void;
29
+ }) {}
30
+
31
+ async finish(
32
+ submissionId: string,
33
+ outcome: SubmissionOutcome,
34
+ message?: string,
35
+ ): Promise<StoredSubmission> {
36
+ const initial = this.read(submissionId);
37
+ if (isTerminalSubmissionStatus(initial.status)) return initial;
38
+ const aborted = Boolean(initial.abortReason) || outcome === "aborted";
39
+ if (aborted) await this.options.releaseAbortedHarness();
40
+ await finishPendingSteers({
41
+ submissionId,
42
+ aborted,
43
+ submissions: this.options.submissions,
44
+ cancelHandoff: (id, reason) => this.commit(id, "aborted", reason),
45
+ steers: this.options.steers,
46
+ messageUi: this.options.messageUi,
47
+ transaction: (run) => this.options.transaction(run),
48
+ conversation: this.options.conversation,
49
+ cancelQueued: (id) => this.options.cancelQueued(id),
50
+ submit: this.options.submit,
51
+ waitUntil: this.options.waitUntil,
52
+ });
53
+ const output = (await this.options.conversation.storedMessages())
54
+ .filter((entry) => entry.submissionId === submissionId && entry.message.role === "assistant")
55
+ .at(-1)?.message;
56
+ return this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
57
+ }
58
+
59
+ /** Shares atomic terminal facts with an admitted handoff cancelled before execution. */
60
+ commit(
61
+ submissionId: string,
62
+ outcome: SubmissionOutcome,
63
+ message?: string,
64
+ output?: AssistantMessage,
65
+ ): StoredSubmission {
66
+ return this.options.transaction(() => {
67
+ // Host admission and history reads can await while an explicit stop wins.
68
+ const latest = this.read(submissionId);
69
+ if (isTerminalSubmissionStatus(latest.status)) return latest;
70
+ const effectiveOutcome = latest.abortReason ? "aborted" : outcome;
71
+ const reason = latest.abortReason ?? message;
72
+ this.options.decisions.cancelForSubmission(submissionId, reason ?? "Turn ended before the decision");
73
+ if (effectiveOutcome === "aborted") {
74
+ this.options.telemetry.capture("turnCancelled", {
75
+ submissionId,
76
+ ...(reason ? { reason } : {}),
77
+ });
78
+ }
79
+ const status = effectiveOutcome === "succeeded" ? "completed" : effectiveOutcome === "aborted" ? "aborted" : "error";
80
+ this.options.submissions.updateTerminal(submissionId, status, reason, Date.now());
81
+ 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
+ );
92
+ }
93
+ this.options.events.submissionTerminal(completed);
94
+ this.options.telemetry.capture("turnFinished", {
95
+ submissionId,
96
+ outcome: effectiveOutcome === "succeeded" ? "completed" : effectiveOutcome === "aborted" ? "cancelled" : "failed",
97
+ durationMs: Math.max(0, (completed.completedAt ?? Date.now()) - latest.createdAt),
98
+ ...(output === undefined ? {} : { output }),
99
+ ...(reason ? { error: reason } : {}),
100
+ occurredAt: completed.completedAt ?? Date.now(),
101
+ });
102
+ return completed;
103
+ });
104
+ }
105
+
106
+ private read(submissionId: string): StoredSubmission {
107
+ const submission = this.options.submissions.find(submissionId);
108
+ if (!submission) throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
109
+ return submission;
110
+ }
111
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./admission";
2
+ export * from "./admission-repository";
3
+ export * from "./contracts";
4
+ export * from "./lifecycle";
5
+ export * from "./repository";
6
+ export * from "./steer-repository";
7
+ export * from "./steer-delivery";
8
+ export * from "./delivery";
9
+ export * from "./finalization";