@springbrand/agent-runtime 0.2.0-alpha.71 → 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.
- package/package.json +3 -3
- package/src/{agent-tool-runtime.ts → adapter/cloudflare/agent-tool.ts} +1 -1
- package/src/adapter/cloudflare/agent.ts +1539 -0
- package/src/{db/index.ts → adapter/cloudflare/database.ts} +18 -55
- package/src/{runtime-agent.ts → adapter/cloudflare/define-agent.ts} +25 -35
- package/src/adapter/cloudflare/dynamic-worker-observability.ts +1 -1
- package/src/adapter/cloudflare/resources/runtime-resources.ts +4 -4
- package/src/adapter/cloudflare/sandbox/adapter.ts +1 -1
- package/src/{db → adapter/cloudflare}/schema.ts +16 -83
- package/src/adapter/cloudflare/subagent/definition.ts +63 -33
- package/src/adapter/cloudflare/subagent/runner.ts +174 -120
- package/src/adapter/cloudflare/subagent/tools.ts +13 -14
- package/src/adapter/cloudflare/universal-agent/preparation.ts +7 -7
- package/src/adapter/cloudflare/universal-agent/tools.ts +10 -10
- package/src/adapter/cloudflare/workspace/git-fs.ts +1 -1
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +1 -1
- package/src/adapter/cloudflare/workspace/version-control.ts +2 -2
- package/src/config/activation.ts +140 -0
- package/src/{runtime-agent-context.ts → config/agent-context.ts} +3 -3
- package/src/{runtime-assembler.ts → config/assembler.ts} +20 -19
- package/src/{kernel/runtime-assembly.ts → config/assembly-projection.ts} +4 -4
- package/src/{pi/assembly/snapshot.ts → config/assembly-snapshot.ts} +6 -6
- package/src/{runtime-definition.ts → config/definition.ts} +13 -13
- package/src/config/harness-runtime.ts +182 -0
- package/src/config/index.ts +14 -0
- package/src/{pi/runtime-adapter/assembly.ts → config/pinning.ts} +38 -16
- package/src/{kernel/bindings.ts → config/ports.ts} +9 -13
- package/src/{kernel → config}/profile.ts +7 -0
- package/src/{kernel → config}/public-contracts.ts +6 -6
- package/src/{kernel → config}/runtime-load.ts +1 -1
- package/src/{pi/assembly/context.ts → context/assembly.ts} +4 -206
- package/src/{pi/assembly/extensions.ts → context/extension/assembly.ts} +5 -5
- package/src/context/extension/index.ts +3 -0
- package/src/context/index.ts +6 -0
- package/src/{layers/context → context/memory}/budget/gate.ts +2 -2
- package/src/conversation/harness-resume.ts +288 -0
- package/src/conversation/harness-stream.ts +98 -0
- package/src/{pi/runtime-adapter/transcript.ts → conversation/history.ts} +206 -233
- package/src/conversation/index.ts +6 -0
- package/src/{pi → conversation}/message/contract.ts +3 -0
- package/src/{pi → conversation}/message/conversion.ts +75 -0
- package/src/{pi → conversation}/message/index.ts +1 -0
- package/src/{pi → conversation}/message/model-input.ts +1 -1
- package/src/{pi → conversation}/message/projection.ts +76 -10
- package/src/{db/message-ui.repo.ts → conversation/repository.ts} +4 -0
- package/src/conversation/state.ts +175 -0
- package/src/harness/index.ts +7 -0
- package/src/harness/session-harness.ts +481 -0
- package/src/harness/submission-operation.ts +49 -0
- package/src/index.ts +38 -43
- package/src/lib/mcp.ts +1 -1
- package/src/{pi/runtime-adapter/models.ts → models/index.ts} +65 -17
- package/src/{pi/turn/tool-recovery.ts → session/cloudflare/legacy-tool-recovery.ts} +33 -525
- package/src/{pi/session → session/cloudflare}/migration.ts +334 -8
- package/src/{pi/session → session/cloudflare}/storage.ts +8 -36
- package/src/session/index.ts +39 -0
- package/src/submission/admission.ts +275 -0
- package/src/{kernel/receipts.ts → submission/contracts.ts} +14 -0
- package/src/submission/delivery.ts +212 -0
- package/src/submission/finalization.ts +111 -0
- package/src/submission/index.ts +9 -0
- package/src/{kernel/submission-lifecycle.ts → submission/lifecycle.ts} +41 -28
- package/src/{db/submission.repo.ts → submission/repository.ts} +61 -118
- package/src/submission/steer-delivery.ts +167 -0
- package/src/{db/steer.repo.ts → submission/steer-repository.ts} +27 -5
- package/src/telemetry/contract.ts +12 -0
- package/src/telemetry/coordinator.ts +65 -0
- package/src/telemetry/delivery.ts +2 -2
- package/src/telemetry/index.ts +5 -0
- package/src/{model-usage.ts → telemetry/model-usage.ts} +1 -1
- package/src/telemetry/recorder.ts +1 -1
- package/src/telemetry/runtime-telemetry.ts +12 -0
- package/src/{kernel/durable-lifecycle.ts → telemetry/subagent-lifecycle.ts} +1 -1
- package/src/telemetry/turn-events.ts +279 -0
- package/src/{pi/tool → tools}/ai-adapter.ts +2 -2
- package/src/tools/approval/index.ts +1 -0
- package/src/{db/approval.repo.ts → tools/approval/repository.ts} +10 -30
- package/src/{pi/tool → tools}/base.ts +14 -30
- package/src/tools/compiler.ts +327 -0
- package/src/{pi/tool → tools}/core-host.ts +3 -3
- package/src/{pi/tool → tools}/core.ts +3 -3
- package/src/tools/decision-gate.ts +227 -0
- package/src/{pi/tool → tools}/gateway.ts +1 -1
- package/src/tools/human-decisions.ts +127 -0
- package/src/tools/index.ts +30 -0
- package/src/tools/interaction/index.ts +1 -0
- package/src/{pi/tool → tools}/mcp.ts +1 -1
- package/src/{pi/tool → tools}/schedule.ts +3 -3
- package/src/{pi/tool → tools}/skill.ts +4 -4
- package/src/{kernel → tools}/subagent-runtime.ts +1 -1
- package/src/{pi/tool → tools}/subagent.ts +4 -4
- package/src/tools/temporary-agent/approvals.ts +126 -0
- package/src/{layers/orchestration/temporary-agent/core.ts → tools/temporary-agent/coordinator.ts} +41 -49
- package/src/tools/temporary-agent/core.ts +53 -0
- package/src/{layers/orchestration → tools}/temporary-agent/runner.ts +1 -1
- package/src/{layers/orchestration → tools}/temporary-agent/workspace.ts +1 -1
- package/src/{pi/tool → tools}/web-search/api.ts +1 -1
- package/src/{pi/tool → tools}/workspace-revision.ts +2 -2
- package/src/{pi/tool → tools}/workspace-sandbox.ts +2 -2
- package/src/{pi/tool → tools}/workspace-state-connector.ts +1 -1
- package/src/db/milestone.repo.ts +0 -104
- package/src/db/settlement.repo.ts +0 -104
- package/src/kernel/approval-lifecycle.ts +0 -638
- package/src/kernel/interaction-lifecycle.ts +0 -433
- package/src/kernel/recoverable-chat-agent.ts +0 -1020
- package/src/kernel/state.ts +0 -91
- package/src/pi/assembly/index.ts +0 -19
- package/src/pi/runtime-adapter/execution.ts +0 -1163
- package/src/pi/runtime-adapter/index.ts +0 -293
- package/src/pi/runtime-adapter/recovery.ts +0 -982
- package/src/pi/session/index.ts +0 -24
- package/src/pi/tool/compiler.ts +0 -483
- package/src/pi/tool/index.ts +0 -38
- package/src/pi/turn/approval.ts +0 -181
- package/src/pi/turn/index.ts +0 -82
- package/src/pi/turn/interaction.ts +0 -181
- package/src/runtime.ts +0 -4760
- /package/src/{kernel/runtime-assembly-view.ts → config/assembly-view.ts} +0 -0
- /package/src/{kernel → config}/degradation.ts +0 -0
- /package/src/{kernel → config}/runtime-config.ts +0 -0
- /package/src/{kernel/extensions.ts → context/extension/contracts.ts} +0 -0
- /package/src/{db/ext-context.repo.ts → context/extension/repository.ts} +0 -0
- /package/src/{pi/runtime-adapter/openrouter-messages.ts → models/openrouter/messages.ts} +0 -0
- /package/src/{pi/runtime-adapter → models/openrouter}/test-fixtures/openrouter-tool-search.sse +0 -0
- /package/src/{db/submission-admission.repo.ts → submission/admission-repository.ts} +0 -0
- /package/src/{db/telemetry-outbox.repo.ts → telemetry/repository.ts} +0 -0
- /package/src/{db/runtime-event-outbox.repo.ts → telemetry/runtime-event-repository.ts} +0 -0
- /package/src/{pi/tool → tools}/declared.ts +0 -0
- /package/src/{db/interaction.repo.ts → tools/interaction/repository.ts} +0 -0
- /package/src/{pi/tool → tools}/nested-tools.ts +0 -0
- /package/src/{kernel/submission-authority.ts → tools/subagent-authority.ts} +0 -0
- /package/src/{db/agent-tool.repo.ts → tools/subagent-run-repository.ts} +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/contract.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/extract/index.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/fanout/index.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/registry.ts +0 -0
- /package/src/{pi/tool → tools}/time.ts +0 -0
- /package/src/{pi/tool → tools}/web-fetch.ts +0 -0
- /package/src/{pi/tool → tools}/web-search/index.ts +0 -0
- /package/src/{pi/tool → tools}/web-search/web-search.ts +0 -0
- /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";
|