@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.18

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.
@@ -1,5 +1,5 @@
1
1
  import type { SqlTaggedTemplate } from "agents/chat";
2
- import type { LaneSnapshot } from "@earendil-works/pi-agent-core";
2
+
3
3
  import type { ExecutionLevel } from "../lib/execution-level";
4
4
  import type { RuntimeUnattendedExecutionGrant } from "./unattended";
5
5
 
@@ -446,27 +446,6 @@ export class SubmissionRepository {
446
446
  `;
447
447
  }
448
448
 
449
- /** Recover ownership from the matching Pi operation, using only its active branch. */
450
- associateSessionEntries(
451
- submissionId: string,
452
- snapshot: Pick<LaneSnapshot, "operation" | "lastResult" | "tipId" | "transcript">,
453
- ): void {
454
- const boundary = snapshot.operation?.id === submissionId
455
- ? { fromTipId: snapshot.operation.fromTipId, tipId: snapshot.tipId }
456
- : snapshot.lastResult?.operationId === submissionId
457
- ? snapshot.lastResult
458
- : undefined;
459
- if (!boundary) return;
460
- const start = boundary.fromTipId === null
461
- ? 0
462
- : snapshot.transcript.findIndex(({ id }) => id === boundary.fromTipId) + 1;
463
- const end = boundary.tipId === null
464
- ? snapshot.transcript.length
465
- : snapshot.transcript.findIndex(({ id }) => id === boundary.tipId) + 1;
466
- for (const entry of snapshot.transcript.slice(start, end || undefined)) {
467
- this.linkSessionEntry(entry.id, submissionId);
468
- }
469
- }
470
449
 
471
450
  listSessionEntrySubmissionLinks(): Map<string, string> {
472
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
  }
@@ -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,
@@ -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
 
@@ -1,4 +1,6 @@
1
- import type { AgentHarnessTool, AgentToolResult } from "@earendil-works/pi-agent-core";
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { ToolRegistration } from "@earendil-works/pi-durable";
3
+ import { awaitWithContext } from "@earendil-works/chord/context";
2
4
  import { boundDurableToolOutput, spillDurableToolOutput } from "../../context/memory/budget/gate";
3
5
  import { artifactId, serializeOutput, sha256Hex, type ArtifactRef, type SpillWorkspace } from "../../lib/artifacts";
4
6
  import type { CompileHarnessToolsOptions, PiToolCandidate } from "./contracts";
@@ -134,29 +136,6 @@ async function durableResult(
134
136
  );
135
137
  }
136
138
 
137
- async function untilAborted<T>(
138
- signal: AbortSignal | undefined,
139
- run: () => T | Promise<T>,
140
- ): Promise<T> {
141
- // ponytail: 这会立即释放 Turn,但无法杀死不配合的进程内副作用;需要物理终止的 Tool 必须放进可销毁的隔离执行器。
142
- signal?.throwIfAborted();
143
- if (!signal) return run();
144
- let onAbort!: () => void;
145
- const aborted = new Promise<never>((_resolve, reject) => {
146
- onAbort = () => reject(
147
- signal.reason ?? new DOMException("Aborted", "AbortError"),
148
- );
149
- signal.addEventListener("abort", onAbort, { once: true });
150
- });
151
- try {
152
- return await Promise.race([run(), aborted]);
153
- } finally {
154
- signal.removeEventListener("abort", onAbort);
155
- }
156
- }
157
-
158
-
159
-
160
139
  /**
161
140
  * Convert the configured candidate surface once at the AgentHarness boundary.
162
141
  * Schema, argument preparation, progress, cancellation, replay classification,
@@ -165,7 +144,7 @@ async function untilAborted<T>(
165
144
  export function compileHarnessTools(
166
145
  candidates: readonly PiToolCandidate[],
167
146
  options: CompileHarnessToolsOptions = {},
168
- ): AgentHarnessTool<object | undefined>[] {
147
+ ): ToolRegistration<any, any>[] {
169
148
  return candidates.map((candidate) => {
170
149
  const source = candidate.tool;
171
150
  const tool = source.constrainedSampling === false
@@ -185,16 +164,21 @@ export function compileHarnessTools(
185
164
  !candidate.requiredExecutionLevelForInput &&
186
165
  !candidate.interaction
187
166
  ? "safe" as const
188
- : "never" as const,
167
+ : "unsafe" as const,
189
168
  ...(candidate.deferLoading ? { deferLoading: true as const } : {}),
190
- async execute(
191
- toolCallId,
192
- args,
193
- onUpdate,
194
- _toolContext,
195
- _invocation,
196
- context,
197
- ) {
169
+ async execute(args, api, context) {
170
+ const toolCallId = api.callId;
171
+ // AgentTool progress is a replacing snapshot (web_search sends its accumulated text), but Pi output only
172
+ // appends and has no replace operation: append a grown snapshot's suffix, and start a new line otherwise.
173
+ let shown = "";
174
+ const onUpdate = (update: AgentToolResult<unknown>) => {
175
+ const text = update.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("");
176
+ if (text && text !== shown) {
177
+ api.output(text.startsWith(shown) ? text.slice(shown.length) : shown ? `\n${text}` : text);
178
+ shown = text;
179
+ }
180
+ if (update.details !== undefined) void api.details(update.details, context).catch(() => {});
181
+ };
198
182
  const signal = context.abortSignal ?? new AbortController().signal;
199
183
  let result: AgentToolResult<unknown>;
200
184
  try {
@@ -208,19 +192,17 @@ export function compileHarnessTools(
208
192
  args,
209
193
  );
210
194
  if (replay) return durableResult(replay, candidate, options.workspace);
211
- result = await untilAborted(
212
- signal,
213
- async () => durableResult(
214
- await (candidate.confirmation
215
- ? (() => {
216
- const confirmed = options.resolveConfirmation?.(candidate, toolCallId, args);
217
- if (!confirmed) throw new Error("User confirmation is required");
218
- return candidate.confirmation.execute(confirmed.prepared, { intentId: confirmed.intentId, signal });
219
- })()
220
- : source.execute(toolCallId, args, signal, (update) => onUpdate(update))),
221
- candidate,
222
- options.workspace,
223
- ),
195
+ signal.throwIfAborted();
196
+ const execution = candidate.confirmation
197
+ ? (() => {
198
+ const confirmed = options.resolveConfirmation?.(candidate, toolCallId, args);
199
+ if (!confirmed) throw new Error("User confirmation is required");
200
+ return candidate.confirmation.execute(confirmed.prepared, { intentId: confirmed.intentId, signal });
201
+ })()
202
+ : source.execute(toolCallId, args, signal, (update) => onUpdate(update));
203
+ result = await awaitWithContext(
204
+ Promise.resolve(execution).then((result) => durableResult(result, candidate, options.workspace)),
205
+ context,
224
206
  );
225
207
  } catch (cause) {
226
208
  const message = errorMessage(cause);
@@ -239,7 +221,7 @@ export function compileHarnessTools(
239
221
  source: candidate.source,
240
222
  });
241
223
  if (cause instanceof UnattendedExecutionError) {
242
- return { ...failed, terminate: true };
224
+ return { ...failed, isError: true, control: { terminate: true as const } };
243
225
  }
244
226
  throw cause instanceof Error && cause.message === message
245
227
  ? cause
@@ -257,8 +239,8 @@ export function compileHarnessTools(
257
239
  summary: candidate.summary,
258
240
  source: candidate.source,
259
241
  });
260
- return result;
242
+ return { ...result, ...(result.terminate ? { control: { terminate: true as const } } : {}) };
261
243
  },
262
- } satisfies AgentHarnessTool<object | undefined>;
244
+ } satisfies ToolRegistration<any, any>;
263
245
  });
264
246
  }
@@ -1,4 +1,4 @@
1
- import type { AgentToolResult, HookHandler } from "@earendil-works/pi-agent-core";
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import type { ExecutionLevel } from "../../lib/execution-level";
3
3
  import {
4
4
  EXECUTION_LEVELS,
@@ -86,7 +86,7 @@ export class HarnessDecisionGate {
86
86
  );
87
87
  }
88
88
 
89
- readonly beforeTool: HookHandler<"before_tool"> = async (event, context) => {
89
+ readonly beforeTool = async (event: { toolName: string; toolCallId: string; args: unknown }, context: import("@earendil-works/chord").Context): Promise<{ block: string } | undefined> => {
90
90
  const candidate = this.candidates.get(event.toolName);
91
91
  if (!candidate) return undefined;
92
92
  let requiredExecutionLevel: ExecutionLevel;
@@ -94,7 +94,7 @@ export class HarnessDecisionGate {
94
94
  requiredExecutionLevel = await this.resolveExecutionLevel(candidate, event.args);
95
95
  } catch (error) {
96
96
  if (!(error instanceof UnattendedExecutionError)) throw error;
97
- return { block: { reason: error.message, terminate: true } };
97
+ return { block: error.message };
98
98
  }
99
99
  const effectiveLevel = this.effectiveExecutionLevel();
100
100
  const needsApproval = candidate.alwaysRequiresApproval ||
@@ -108,7 +108,7 @@ export class HarnessDecisionGate {
108
108
  }, requiredExecutionLevel);
109
109
  } catch (error) {
110
110
  if (!(error instanceof UnattendedExecutionError)) throw error;
111
- return { block: { reason: error.message, terminate: true } };
111
+ return { block: error.message };
112
112
  }
113
113
  const existing = this.options.approvals.find(executionId);
114
114
  if (existing && (existing.requestId !== this.options.submission.requestId ||
@@ -116,7 +116,7 @@ export class HarnessDecisionGate {
116
116
  throw new Error(`Conflicting durable Tool approval: ${event.toolCallId}`);
117
117
  }
118
118
  if (existing?.status === "rejected") {
119
- return { block: { reason: existing.reason ?? "Tool execution denied by user" } };
119
+ return { block: existing.reason ?? "Tool execution denied by user" };
120
120
  }
121
121
  if (needsApproval && !unattendedAuthorized && existing?.status !== "approved") {
122
122
  if (!existing) {