@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,49 +1,24 @@
1
- import type { OperationRequest, OperationResultRecord } from "@earendil-works/pi-agent-core";
2
- import type { Context } from "@earendil-works/pi-agent-core/harness/context";
1
+ import type { Context } from "@earendil-works/chord";
2
+ import type { EntryId } from "@earendil-works/pi-durable";
3
3
  import type { PiCanonicalUserInput } from "../conversation/message/contract";
4
4
  import type { StoredSubmission } from "../submission/repository";
5
- import type { SessionHarness } from "./session-harness";
5
+ import type { SessionHarness, SessionRunRequest } from "./session-harness";
6
6
 
7
- /** Recover the stable operation before accepting new input or navigating its branch. */
7
+ /** Reacquire Pi's stable Submission before admitting another model run. */
8
8
  export async function prepareSubmissionOperation(
9
- runtime: SessionHarness<object | undefined>,
9
+ runtime: SessionHarness,
10
10
  submission: Pick<StoredSubmission, "submissionId" | "queuedInputJson" | "regenerateMessageId">,
11
- regenerationTarget: (messageId: string) => string | null,
11
+ regenerationTarget: (messageId: string) => Promise<EntryId | null>,
12
12
  context: Context,
13
- ): Promise<{ kind: "run"; request?: OperationRequest } | { kind: "settled"; outcome: OperationResultRecord }> {
14
- const id = submission.submissionId;
15
- const navigationId = `${id}:regenerate`;
16
- if (runtime.openOperation?.operationId === navigationId) {
17
- const navigation = await runtime.run(undefined, context);
18
- if (navigation.kind !== "settled" || navigation.outcome.status !== "completed") {
19
- throw new Error("Could not recover the regeneration branch navigation");
20
- }
21
- }
22
- if (runtime.openOperation) {
23
- if (runtime.openOperation.operationId !== id) {
24
- throw new Error(`Submission ${id} does not own the open Harness operation`);
25
- }
26
- return { kind: "run" };
27
- }
28
- const watch = await runtime.lane.watch(context);
29
- const previous = watch.snapshot.lastResult;
30
- watch.unsubscribe();
31
- if (previous?.operationId === id) return { kind: "settled", outcome: previous };
32
- if (!submission.queuedInputJson) {
33
- throw new Error(`Submission ${id} has no open Harness operation or queued input`);
34
- }
13
+ ): Promise<SessionRunRequest> {
14
+ const existing = await runtime.submissionFor(submission.submissionId, context);
15
+ if (existing) return { operationId: submission.submissionId };
16
+ if (!submission.queuedInputJson) throw new Error("The admitted Submission has no canonical input");
35
17
  const prompt = JSON.parse(submission.queuedInputJson) as PiCanonicalUserInput;
36
- if (prompt.role !== "user") throw new Error("Queued SpringBrand user message is invalid");
37
- if (submission.regenerateMessageId && previous?.operationId !== navigationId) {
38
- const navigation = await runtime.run({
39
- kind: "navigation",
40
- operationId: navigationId,
41
- targetId: regenerationTarget(submission.regenerateMessageId),
42
- options: { summarize: false },
43
- }, context);
44
- if (navigation.kind !== "settled" || navigation.outcome.status !== "completed") {
45
- throw new Error("Could not navigate to the regeneration branch");
46
- }
18
+ if (prompt.role !== "user") throw new Error("Invalid canonical user input");
19
+ if (submission.regenerateMessageId) {
20
+ const target = await regenerationTarget(submission.regenerateMessageId);
21
+ await runtime.fork(target, context);
47
22
  }
48
- return { kind: "run", request: { kind: "prompt", operationId: id, prompt } };
23
+ return { operationId: submission.submissionId, prompt };
49
24
  }
@@ -537,14 +537,15 @@ export function modelRequestUrl(model: Model<Api>): string {
537
537
  */
538
538
  export function withProviderBoundary(
539
539
  streamFn: ProviderBoundStream,
540
- defaultSessionId?: string,
540
+ /** Host-owned provider Session; it overrides the durable Harness's per-conversation identity. */
541
+ hostSessionId?: string | (() => string),
541
542
  onGeneration?: PiGenerationLifecycleObserver,
542
543
  ): ProviderBoundStream {
543
544
  return (model, context, options) =>
544
545
  lazyStream(model, async () => {
545
546
  const requestId = crypto.randomUUID();
546
547
  const startedAt = Date.now();
547
- const sessionId = options?.sessionId ?? defaultSessionId;
548
+ const sessionId = (typeof hostSessionId === "function" ? hostSessionId() : hostSessionId) ?? options?.sessionId;
548
549
  const url = modelRequestUrl(model);
549
550
  const systemPrompt = "systemPrompt" in context && typeof context.systemPrompt === "string"
550
551
  ? context.systemPrompt
@@ -910,11 +911,15 @@ function configuredModel(
910
911
  ...(endpoint.protocol === "openrouter-messages"
911
912
  ? {
912
913
  ...anthropicMessagesCompat,
914
+ // The direct Anthropic catalog enables positional system and Tool updates, but
915
+ // OpenRouter Messages rejects `tool_addition` definitions. Fold them into the
916
+ // leading system and top-level tools so deferred Tool decoration still applies.
917
+ supportsMidConvoSystemMessages: false,
918
+ supportsMidConvoToolChanges: false,
913
919
  supportsMidConvoEffort: false,
914
920
  supportsEagerToolInputStreaming: false,
915
921
  supportsStrictTools:
916
922
  anthropicMessagesCompat?.supportsStrictTools ?? false,
917
- supportsToolReferences: true,
918
923
  ...(modelId === "anthropic/claude-sonnet-5.5" ||
919
924
  modelId === "anthropic/claude-opus-5.5"
920
925
  ? { forceAdaptiveThinking: true }
@@ -923,6 +928,9 @@ function configuredModel(
923
928
  : endpoint.protocol === "openrouter-chat"
924
929
  ? {
925
930
  ...catalogCompat,
931
+ // OpenRouter Chat has no verified positional system or Tool update support.
932
+ supportsMidConvoSystemMessages: false,
933
+ supportsMidConvoToolChanges: false,
926
934
  thinkingFormat: "openrouter" as const,
927
935
  maxTokensField: "max_tokens" as const,
928
936
  supportsStore: false,
@@ -942,6 +950,9 @@ function configuredModel(
942
950
  }),
943
951
  openRouterRouting: {
944
952
  require_parameters: true,
953
+ ...(endpoint.openRouterProviderSort
954
+ ? { sort: endpoint.openRouterProviderSort }
955
+ : {}),
945
956
  ...(openRouterProviderPin
946
957
  ? {
947
958
  order: [openRouterProviderPin],
@@ -1,6 +1,5 @@
1
1
  import {
2
- createAssistantMessageEventStream,
3
- fauxAssistantMessage,
2
+ lazyStream,
4
3
  type Api,
5
4
  type AssistantMessage,
6
5
  type AssistantMessageEventStream,
@@ -13,15 +12,11 @@ import { overlayProviderStreams } from "../provider-streams";
13
12
 
14
13
  export type OpenRouterProtocol = Extract<RuntimeModelProtocol, `openrouter-${string}`>;
15
14
 
16
- /** Minimal native output retained for the next OpenRouter Responses request. */
15
+ /** Native output required by OpenRouter's stateless continuation. */
17
16
  export interface ModelProtocolResponse {
18
17
  readonly output: readonly Readonly<Record<string, unknown>>[];
19
18
  }
20
19
 
21
- interface ProtocolEvent {
22
- readonly data: unknown;
23
- }
24
-
25
20
  declare module "@earendil-works/pi-ai" {
26
21
  interface AssistantMessage {
27
22
  protocolResponse?: ModelProtocolResponse;
@@ -34,113 +29,46 @@ function record(value: unknown): Record<string, unknown> | undefined {
34
29
  : undefined;
35
30
  }
36
31
 
37
- function parseProtocolEvents(body: string): ProtocolEvent[] {
38
- return body.split(/\r?\n\r?\n/).flatMap((frame) => {
39
- const lines = frame.split(/\r?\n/);
40
- const dataLines = lines.filter((line) => line.startsWith("data:"))
41
- .map((line) => line.slice(5).replace(/^ /, ""));
42
- if (dataLines.length === 0) return [];
43
- const dataText = dataLines.join("\n");
44
- let data: unknown = dataText;
45
- try {
46
- data = JSON.parse(dataText);
47
- } catch {
48
- data = dataText;
49
- }
50
- return [{ data }];
51
- });
52
- }
53
-
54
- function compactResponsesBody(body: string): ModelProtocolResponse | undefined {
55
- const completedItems = new Map<number, Record<string, unknown>>();
56
- let terminalItems: Record<string, unknown>[] | undefined;
57
- let terminal = false;
58
- for (const frame of parseProtocolEvents(body)) {
59
- const event = record(frame.data);
60
- if (
61
- event?.type === "response.output_item.done" &&
62
- typeof event.output_index === "number"
63
- ) {
64
- const item = record(event.item);
65
- if (item) completedItems.set(event.output_index, item);
66
- }
67
- if (event?.type === "response.completed" || event?.type === "response.incomplete") {
68
- terminal = true;
69
- const response = record(event.response);
70
- if (Array.isArray(response?.output)) {
71
- terminalItems = response.output.flatMap((value) => {
72
- const output = record(value);
73
- return output ? [output] : [];
74
- });
75
- }
76
- }
77
- }
78
- if (!terminal) return undefined;
79
- const output = terminalItems?.length
80
- ? terminalItems.map((item, index) => ({ ...completedItems.get(index), ...item }))
81
- : [...completedItems.entries()]
82
- .sort(([left], [right]) => left - right)
83
- .map(([, item]) => item);
84
- return output.length > 0 ? { output } : undefined;
85
- }
86
-
87
32
  function streamWithProtocolResponse<T extends StreamOptions>(
88
33
  model: Model<Api>,
89
34
  options: T | undefined,
90
35
  start: (options: T) => AssistantMessageEventStream,
91
36
  ): AssistantMessageEventStream {
92
- const output = createAssistantMessageEventStream();
93
- const fetcher = options?.fetch ?? globalThis.fetch;
94
- let responseOutput: Promise<ModelProtocolResponse | undefined> | undefined;
95
- let terminal = false;
96
- const requestOptions = {
97
- ...options,
98
- fetch: (async (...args: Parameters<typeof fetch>) => {
99
- const response = await fetcher(...args);
100
- responseOutput = response.clone().text()
101
- .then((body) => compactResponsesBody(body))
102
- .catch(() => undefined);
103
- return response;
104
- }) satisfies typeof fetch,
105
- } as T;
106
-
107
- void (async () => {
108
- const source = start(requestOptions);
109
- for await (const event of source) {
110
- if (event.type === "done" || event.type === "error") {
111
- terminal = true;
112
- if (responseOutput) {
113
- const message = event.type === "done" ? event.message : event.error;
114
- const compact = await responseOutput;
115
- if (compact) message.protocolResponse = compact;
37
+ return lazyStream(model, async () => {
38
+ let nativeResponse: ModelProtocolResponse | undefined;
39
+ const source = start({
40
+ ...options,
41
+ onProviderStreamEvent: async (data: unknown, activeModel: Model<Api>) => {
42
+ const event = record(data);
43
+ if (event?.type === "response.created") nativeResponse = undefined;
44
+ if (event?.type === "response.completed" || event?.type === "response.incomplete") {
45
+ const output = record(event.response)?.output;
46
+ if (Array.isArray(output)) {
47
+ const items = output.flatMap((value) => {
48
+ const item = record(value);
49
+ return item ? [item] : [];
50
+ });
51
+ nativeResponse = items.length > 0 ? { output: items } : undefined;
52
+ }
53
+ }
54
+ await options?.onProviderStreamEvent?.(data, activeModel);
55
+ },
56
+ } as T);
57
+ return (async function* () {
58
+ let terminal = false;
59
+ for await (const event of source) {
60
+ if (event.type === "done" || event.type === "error") {
61
+ terminal = true;
62
+ if (nativeResponse) {
63
+ const message = event.type === "done" ? event.message : event.error;
64
+ message.protocolResponse = nativeResponse;
65
+ }
116
66
  }
67
+ yield event;
117
68
  }
118
- output.push(event);
119
- }
120
- if (!terminal) throw new Error("Provider stream ended without a terminal event");
121
- output.end();
122
- })().catch(async (error: unknown) => {
123
- if (terminal) {
124
- output.end();
125
- return;
126
- }
127
- const reason = options?.signal?.aborted ? "aborted" : "error";
128
- const failure = fauxAssistantMessage("", {
129
- stopReason: reason,
130
- errorMessage: error instanceof Error ? error.message : String(error),
131
- });
132
- if (responseOutput) {
133
- const compact = await responseOutput;
134
- if (compact) failure.protocolResponse = compact;
135
- }
136
- output.push({
137
- type: "error",
138
- reason,
139
- error: { ...failure, api: model.api, provider: model.provider, model: model.id },
140
- });
141
- output.end();
69
+ if (!terminal) throw new Error("Provider stream ended without a terminal event");
70
+ })();
142
71
  });
143
- return output;
144
72
  }
145
73
 
146
74
  export function withModelProtocolResponse(
@@ -150,12 +78,8 @@ export function withModelProtocolResponse(
150
78
  if (protocol !== "openrouter-responses") return api;
151
79
  return overlayProviderStreams(api, {
152
80
  stream: (model, context, options) =>
153
- streamWithProtocolResponse(model, options, (requestOptions) =>
154
- api.stream(model, context, requestOptions)
155
- ),
81
+ streamWithProtocolResponse(model, options, (requestOptions) => api.stream(model, context, requestOptions)),
156
82
  streamSimple: (model, context, options) =>
157
- streamWithProtocolResponse(model, options, (requestOptions) =>
158
- api.streamSimple(model, context, requestOptions)
159
- ),
83
+ streamWithProtocolResponse(model, options, (requestOptions) => api.streamSimple(model, context, requestOptions)),
160
84
  });
161
85
  }