@springbrand/agent-runtime 0.3.0-alpha.17 → 0.3.0-alpha.19

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.
@@ -13,12 +13,14 @@ import type { SteerRepository } from "./steer-repository";
13
13
 
14
14
  /** Commits the Submission outcome and human/Host facts after accepted steering is preserved. */
15
15
  export class SubmissionFinalizer {
16
+ /** User-stop markers appended after their terminal commit; every `finish()` of that Turn awaits it. */
17
+ private readonly stopMarkers = new Map<string, Promise<unknown>>();
16
18
  constructor(private readonly options: {
17
19
  submissions: SubmissionRepository;
18
20
  steers: SteerRepository;
19
21
  messageUi: MessageUiRepository;
20
22
  transaction<T>(run: () => T): T;
21
- conversation: Pick<ConversationHistory, "append" | "storedMessages">;
23
+ conversation: Pick<ConversationHistory, "append" | "appendTurnAbortedMarker" | "storedMessages">;
22
24
  decisions: Pick<HarnessHumanDecisions, "cancelForSubmission">;
23
25
  events: Pick<RuntimeTurnEvents, "submissionTerminal">;
24
26
  telemetry: Pick<RuntimeTelemetryCoordinator, "capture">;
@@ -34,7 +36,12 @@ export class SubmissionFinalizer {
34
36
  message?: string,
35
37
  ): Promise<StoredSubmission> {
36
38
  const initial = this.read(submissionId);
37
- if (isTerminalSubmissionStatus(initial.status)) return initial;
39
+ if (isTerminalSubmissionStatus(initial.status)) {
40
+ // A stop RPC and the ending harness finish the same Turn; whichever loses
41
+ // still waits for the stop marker, so its history broadcast carries it.
42
+ await this.stopMarkers.get(submissionId);
43
+ return initial;
44
+ }
38
45
  const aborted = Boolean(initial.abortReason) || outcome === "aborted";
39
46
  if (aborted) await this.options.releaseAbortedHarness();
40
47
  await finishPendingSteers({
@@ -53,7 +60,13 @@ export class SubmissionFinalizer {
53
60
  const output = (await this.options.conversation.storedMessages())
54
61
  .filter((entry) => entry.submissionId === submissionId && entry.message.role === "assistant")
55
62
  .at(-1)?.message;
56
- return this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
63
+ const completed = this.commit(submissionId, outcome, message, output?.role === "assistant" ? output : undefined);
64
+ const marker = this.stopMarkers.get(submissionId);
65
+ if (marker) {
66
+ await marker;
67
+ this.stopMarkers.delete(submissionId);
68
+ }
69
+ return completed;
57
70
  }
58
71
 
59
72
  /** Shares atomic terminal facts with an admitted handoff cancelled before execution. */
@@ -63,7 +76,8 @@ export class SubmissionFinalizer {
63
76
  message?: string,
64
77
  output?: AssistantMessage,
65
78
  ): StoredSubmission {
66
- return this.options.transaction(() => {
79
+ let stoppedByUser = false;
80
+ const completed = this.options.transaction(() => {
67
81
  // Host admission and history reads can await while an explicit stop wins.
68
82
  const latest = this.read(submissionId);
69
83
  if (isTerminalSubmissionStatus(latest.status)) return latest;
@@ -79,6 +93,10 @@ export class SubmissionFinalizer {
79
93
  const status = effectiveOutcome === "succeeded" ? "completed" : effectiveOutcome === "aborted" ? "aborted" : "error";
80
94
  this.options.submissions.updateTerminal(submissionId, status, reason, Date.now());
81
95
  const completed = this.read(submissionId);
96
+ stoppedByUser = effectiveOutcome === "aborted" && reason === USER_STOP_REASON;
97
+ if (stoppedByUser && completed.completedAt === null) {
98
+ throw new Error(`Aborted SpringBrand submission has no completion time: ${submissionId}`);
99
+ }
82
100
  this.options.events.submissionTerminal(completed);
83
101
  this.options.telemetry.capture("turnFinished", {
84
102
  submissionId,
@@ -90,6 +108,20 @@ export class SubmissionFinalizer {
90
108
  });
91
109
  return completed;
92
110
  });
111
+ if (stoppedByUser) this.appendStopMarker(submissionId, completed.completedAt!);
112
+ return completed;
113
+ }
114
+
115
+ /**
116
+ * The browser projects `interruptedByUser` from this marker and the next model request sees it as user context.
117
+ * Pi history writes are async, so the marker follows the synchronous terminal commit.
118
+ */
119
+ private appendStopMarker(submissionId: string, completedAt: number): void {
120
+ const marker = this.options.conversation.appendTurnAbortedMarker(submissionId, completedAt).catch((error: unknown) => {
121
+ console.warn("[runtime-stop-marker:failed]", submissionId, error instanceof Error ? error.message : String(error));
122
+ });
123
+ this.stopMarkers.set(submissionId, marker);
124
+ this.options.waitUntil(marker);
93
125
  }
94
126
 
95
127
  private read(submissionId: string): StoredSubmission {
@@ -168,8 +168,15 @@ export function compileHarnessTools(
168
168
  ...(candidate.deferLoading ? { deferLoading: true as const } : {}),
169
169
  async execute(args, api, context) {
170
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 = "";
171
174
  const onUpdate = (update: AgentToolResult<unknown>) => {
172
- for (const part of update.content) if (part.type === "text") api.output(part.text);
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
+ }
173
180
  if (update.details !== undefined) void api.details(update.details, context).catch(() => {});
174
181
  };
175
182
  const signal = context.abortSignal ?? new AbortController().signal;