@linxiraos/pi-agent-core 1.1.27 → 1.2.0

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.
@@ -2,7 +2,7 @@
2
2
  * OpenTelemetry instrumentation for the agent loop.
3
3
  *
4
4
  * Implements the OpenTelemetry GenAI semantic conventions
5
- * (https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus `omp.gen_ai.*`
5
+ * (https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus `zeta.gen_ai.*`
6
6
  * extension attributes for run summaries, dashboard summaries, and cost hints
7
7
  * that are useful to downstream observability UIs.
8
8
  *
@@ -79,40 +79,40 @@ export declare const enum OpenAIAttr {
79
79
  }
80
80
  /** Project extension attributes. Kept out of the reserved `gen_ai.*` namespace. */
81
81
  export declare const enum OmpGenAIAttr {
82
- AgentStepNumber = "omp.gen_ai.agent.step.number",
83
- AgentStepCount = "omp.gen_ai.agent.step.count",
84
- RequestReasoningEffort = "omp.gen_ai.request.reasoning.effort",
85
- RequestToolChoice = "omp.gen_ai.request.tool.choice",
86
- RequestAvailableTools = "omp.gen_ai.request.available_tools",
87
- RequestMessages = "omp.gen_ai.request.messages",
88
- ResponseText = "omp.gen_ai.response.text",
89
- ResponseToolCalls = "omp.gen_ai.response.tool_calls",
90
- ResponseUpstreamProvider = "omp.gen_ai.response.upstream_provider",
91
- UsageTotalTokens = "omp.gen_ai.usage.total_tokens",
92
- UsageServerSideTools = "omp.gen_ai.usage.server_tool_requests",
93
- CostEstimatedUsd = "omp.gen_ai.cost.estimated_usd",
94
- CostInputUsd = "omp.gen_ai.cost.input_usd",
95
- CostOutputUsd = "omp.gen_ai.cost.output_usd",
96
- CostUnavailableReason = "omp.gen_ai.cost.unavailable_reason",
97
- ToolStatus = "omp.gen_ai.tool.status",
98
- ToolCallIntent = "omp.gen_ai.tool.call.intent",
99
- HandoffFromAgentName = "omp.gen_ai.handoff.from_agent.name",
100
- HandoffFromAgentId = "omp.gen_ai.handoff.from_agent.id",
101
- HandoffToAgentName = "omp.gen_ai.handoff.to_agent.name",
102
- HandoffToAgentId = "omp.gen_ai.handoff.to_agent.id",
103
- OneshotKind = "omp.gen_ai.oneshot.kind",
104
- GatewayName = "omp.gen_ai.gateway.name",
105
- GatewayEndpoint = "omp.gen_ai.gateway.endpoint",
106
- GatewayCallId = "omp.gen_ai.gateway.call_id",
107
- GatewayRoutedTo = "omp.gen_ai.gateway.routed_to",
82
+ AgentStepNumber = "zeta.gen_ai.agent.step.number",
83
+ AgentStepCount = "zeta.gen_ai.agent.step.count",
84
+ RequestReasoningEffort = "zeta.gen_ai.request.reasoning.effort",
85
+ RequestToolChoice = "zeta.gen_ai.request.tool.choice",
86
+ RequestAvailableTools = "zeta.gen_ai.request.available_tools",
87
+ RequestMessages = "zeta.gen_ai.request.messages",
88
+ ResponseText = "zeta.gen_ai.response.text",
89
+ ResponseToolCalls = "zeta.gen_ai.response.tool_calls",
90
+ ResponseUpstreamProvider = "zeta.gen_ai.response.upstream_provider",
91
+ UsageTotalTokens = "zeta.gen_ai.usage.total_tokens",
92
+ UsageServerSideTools = "zeta.gen_ai.usage.server_tool_requests",
93
+ CostEstimatedUsd = "zeta.gen_ai.cost.estimated_usd",
94
+ CostInputUsd = "zeta.gen_ai.cost.input_usd",
95
+ CostOutputUsd = "zeta.gen_ai.cost.output_usd",
96
+ CostUnavailableReason = "zeta.gen_ai.cost.unavailable_reason",
97
+ ToolStatus = "zeta.gen_ai.tool.status",
98
+ ToolCallIntent = "zeta.gen_ai.tool.call.intent",
99
+ HandoffFromAgentName = "zeta.gen_ai.handoff.from_agent.name",
100
+ HandoffFromAgentId = "zeta.gen_ai.handoff.from_agent.id",
101
+ HandoffToAgentName = "zeta.gen_ai.handoff.to_agent.name",
102
+ HandoffToAgentId = "zeta.gen_ai.handoff.to_agent.id",
103
+ OneshotKind = "zeta.gen_ai.oneshot.kind",
104
+ GatewayName = "zeta.gen_ai.gateway.name",
105
+ GatewayEndpoint = "zeta.gen_ai.gateway.endpoint",
106
+ GatewayCallId = "zeta.gen_ai.gateway.call_id",
107
+ GatewayRoutedTo = "zeta.gen_ai.gateway.routed_to",
108
108
  /** Cloudflare AI Gateway response-cache status (`cf-aig-cache-status`), never prompt-cache. */
109
- GatewayResponseCacheStatus = "omp.gen_ai.gateway.response_cache.status",
109
+ GatewayResponseCacheStatus = "zeta.gen_ai.gateway.response_cache.status",
110
110
  /** Caller-level reason a judgment ran (`find`, `ttsr`, `judge_batch`, …). */
111
- JudgmentPurpose = "omp.gen_ai.judgment.purpose",
111
+ JudgmentPurpose = "zeta.gen_ai.judgment.purpose",
112
112
  /** Questions the caller asked in one judgment request. */
113
- JudgmentQuestions = "omp.gen_ai.judgment.questions",
113
+ JudgmentQuestions = "zeta.gen_ai.judgment.questions",
114
114
  /** Questions answered from the local judgment cache instead of the provider. */
115
- JudgmentCachedQuestions = "omp.gen_ai.judgment.cached_questions"
115
+ JudgmentCachedQuestions = "zeta.gen_ai.judgment.cached_questions"
116
116
  }
117
117
  /** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
118
118
  export declare const GenAIOperation: {
@@ -160,9 +160,9 @@ export interface CostEstimatorContext {
160
160
  }
161
161
  /**
162
162
  * Cost estimator result.
163
- * { usd: number } — cost is known; emitted as omp.gen_ai.cost.estimated_usd
163
+ * { usd: number } — cost is known; emitted as zeta.gen_ai.cost.estimated_usd
164
164
  * { unavailable: string } — cost is intentionally unknown; emitted as
165
- * omp.gen_ai.cost.unavailable_reason
165
+ * zeta.gen_ai.cost.unavailable_reason
166
166
  * undefined — no opinion; nothing emitted
167
167
  */
168
168
  export type CostEstimate = {
@@ -213,7 +213,7 @@ export interface ChatUsageEvent {
213
213
  *
214
214
  * Use this to reconcile gateway-issued ids (e.g. `x-litellm-call-id`) with
215
215
  * downstream billing / spend dashboards. Known gateway patterns are also
216
- * auto-stamped on the chat span as `omp.gen_ai.gateway.*` attributes.
216
+ * auto-stamped on the chat span as `zeta.gen_ai.gateway.*` attributes.
217
217
  */
218
218
  readonly headers: Readonly<Record<string, string>> | undefined;
219
219
  }
@@ -518,10 +518,10 @@ export interface InstrumentedChatSpanOptions {
518
518
  /** Step index recorded on the span; defaults to `-1` for non-loop calls. */
519
519
  readonly stepNumber?: number;
520
520
  /**
521
- * Tag stamped onto `omp.gen_ai.oneshot.kind`. Values used by the agent:
521
+ * Tag stamped onto `zeta.gen_ai.oneshot.kind`. Values used by the agent:
522
522
  * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
523
- * `handoff`, `branch_summary`, `image_question`. Free-form to allow callers
524
- * outside this package to add new kinds without bumping the helper.
523
+ * `handoff`, `branch_summary`, `image_question`, `skill_description`. Free-form
524
+ * to allow callers outside this package to add new kinds without bumping the helper.
525
525
  */
526
526
  readonly oneshotKind?: string;
527
527
  /** Extra span attributes applied verbatim. */
@@ -623,30 +623,30 @@ export declare function finishInvokeAgentSpan(telemetry: AgentTelemetry | undefi
623
623
  * calling this helper.
624
624
  */
625
625
  export declare function fireOnRunEnd(telemetry: AgentTelemetry, summary: AgentRunSummary, coverage: AgentRunCoverage): void;
626
- /** Aggregate `omp.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
626
+ /** Aggregate `zeta.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
627
627
  export declare const enum OmpGenAIAggregateAttr {
628
- ChatsCount = "omp.gen_ai.agent.chats.count",
629
- ChatsTotalLatencyMs = "omp.gen_ai.agent.chats.total_latency_ms",
630
- ChatsStopReasonPrefix = "omp.gen_ai.agent.chats.stop_reason.",
631
- ToolsCount = "omp.gen_ai.agent.tools.count",
632
- ToolsOkCount = "omp.gen_ai.agent.tools.ok.count",
633
- ToolsErrorCount = "omp.gen_ai.agent.tools.error.count",
634
- ToolsSkippedCount = "omp.gen_ai.agent.tools.skipped.count",
635
- ToolsBlockedCount = "omp.gen_ai.agent.tools.blocked.count",
636
- ToolsTimeoutCount = "omp.gen_ai.agent.tools.timeout.count",
637
- ToolsAbortedCount = "omp.gen_ai.agent.tools.aborted.count",
638
- ToolsTotalLatencyMs = "omp.gen_ai.agent.tools.total_latency_ms",
639
- ToolsInvoked = "omp.gen_ai.agent.tools.invoked",
640
- ToolsAvailable = "omp.gen_ai.agent.tools.available",
641
- ToolsUnused = "omp.gen_ai.agent.tools.unused",
642
- UsageInputTokensTotal = "omp.gen_ai.agent.usage.input_tokens.total",
643
- UsageOutputTokensTotal = "omp.gen_ai.agent.usage.output_tokens.total",
644
- UsageCacheReadInputTokensTotal = "omp.gen_ai.agent.usage.cache_read.input_tokens.total",
645
- UsageCacheCreationInputTokensTotal = "omp.gen_ai.agent.usage.cache_creation.input_tokens.total",
646
- UsageReasoningOutputTokensTotal = "omp.gen_ai.agent.usage.reasoning.output_tokens.total",
647
- UsageTotalTokensTotal = "omp.gen_ai.agent.usage.total_tokens.total",
648
- CostEstimatedUsdTotal = "omp.gen_ai.agent.cost.estimated_usd.total",
649
- ErrorsCount = "omp.gen_ai.agent.errors.count"
628
+ ChatsCount = "zeta.gen_ai.agent.chats.count",
629
+ ChatsTotalLatencyMs = "zeta.gen_ai.agent.chats.total_latency_ms",
630
+ ChatsStopReasonPrefix = "zeta.gen_ai.agent.chats.stop_reason.",
631
+ ToolsCount = "zeta.gen_ai.agent.tools.count",
632
+ ToolsOkCount = "zeta.gen_ai.agent.tools.ok.count",
633
+ ToolsErrorCount = "zeta.gen_ai.agent.tools.error.count",
634
+ ToolsSkippedCount = "zeta.gen_ai.agent.tools.skipped.count",
635
+ ToolsBlockedCount = "zeta.gen_ai.agent.tools.blocked.count",
636
+ ToolsTimeoutCount = "zeta.gen_ai.agent.tools.timeout.count",
637
+ ToolsAbortedCount = "zeta.gen_ai.agent.tools.aborted.count",
638
+ ToolsTotalLatencyMs = "zeta.gen_ai.agent.tools.total_latency_ms",
639
+ ToolsInvoked = "zeta.gen_ai.agent.tools.invoked",
640
+ ToolsAvailable = "zeta.gen_ai.agent.tools.available",
641
+ ToolsUnused = "zeta.gen_ai.agent.tools.unused",
642
+ UsageInputTokensTotal = "zeta.gen_ai.agent.usage.input_tokens.total",
643
+ UsageOutputTokensTotal = "zeta.gen_ai.agent.usage.output_tokens.total",
644
+ UsageCacheReadInputTokensTotal = "zeta.gen_ai.agent.usage.cache_read.input_tokens.total",
645
+ UsageCacheCreationInputTokensTotal = "zeta.gen_ai.agent.usage.cache_creation.input_tokens.total",
646
+ UsageReasoningOutputTokensTotal = "zeta.gen_ai.agent.usage.reasoning.output_tokens.total",
647
+ UsageTotalTokensTotal = "zeta.gen_ai.agent.usage.total_tokens.total",
648
+ CostEstimatedUsdTotal = "zeta.gen_ai.agent.cost.estimated_usd.total",
649
+ ErrorsCount = "zeta.gen_ai.agent.errors.count"
650
650
  }
651
651
  /**
652
652
  * Run `fn` with `span` activated on the OTEL context. Spans created
@@ -0,0 +1,19 @@
1
+ import { type ToolCall } from "@linxiraos/pi-ai";
2
+ import type { AgentTool } from "./types.js";
3
+ /**
4
+ * Where a call's `__parseError`/`__rawJson` keys came from.
5
+ * - `"parsed"`: a provider stream parsed the arguments, so `__parseError` is a
6
+ * genuine parse-failure sentinel and is never handed to a tool.
7
+ * - `"payload"`: the arguments are a payload the model wrote (eval tool bridge,
8
+ * `xd://` device content), so those keys are forged and only stripped.
9
+ */
10
+ export type ToolArgumentSource = "parsed" | "payload";
11
+ /**
12
+ * Validate a call's arguments against `tool`'s schema, honoring
13
+ * {@link AgentTool.lenientArgValidation}: on a schema mismatch a lenient tool
14
+ * receives its raw arguments minus the internal `__parseError`/`__rawJson`
15
+ * sentinels. Malformed JSON from a provider stream is never lenient.
16
+ *
17
+ * @throws the validation error when the tool is strict or the arguments failed to parse.
18
+ */
19
+ export declare function validateAgentToolArguments(tool: AgentTool, toolCall: ToolCall, source?: ToolArgumentSource): ToolCall["arguments"];
@@ -269,6 +269,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
269
269
  * background.
270
270
  */
271
271
  hasBackgroundCompletions?: () => boolean | Promise<boolean>;
272
+ /**
273
+ * Peeks whether a passive aside (an extension or user message queued with
274
+ * `deliverAs: "aside"`) is waiting for injection at the next boundary.
275
+ *
276
+ * Same rules as {@link hasBackgroundCompletions}: non-consuming, ends only
277
+ * *interruptible* waits, never raises {@link ToolCallContext.steeringSignal}.
278
+ * The host still injects the aside once at the boundary; ordinary running
279
+ * tools finish first.
280
+ */
281
+ hasQueuedAsides?: () => boolean | Promise<boolean>;
272
282
  /**
273
283
  * Returns follow-up messages to process after the agent would otherwise stop.
274
284
  *
@@ -281,7 +291,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
281
291
  * Returns non-interrupting "aside" messages to inject at a step boundary.
282
292
  *
283
293
  * Polled after each tool batch (before the next LLM call) AND at the yield
284
- * check. Unlike steering, these NEVER abort in-flight tools — they are passive
294
+ * check. Unlike steering, these never abort foreground tools (only an
295
+ * interruptible `wait` ends early, via the peek hooks) — they are passive
285
296
  * notifications (e.g. background-job completions, late LSP diagnostics) that
286
297
  * should reach the model between requests without waiting for the agent to
287
298
  * fully stop. Returned messages are appended to the context with normal
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-agent-core",
4
- "version": "1.1.27",
4
+ "version": "1.2.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Linxira-OS",
@@ -35,16 +35,16 @@
35
35
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
36
36
  },
37
37
  "dependencies": {
38
- "@linxiraos/pi-ai": "1.1.27",
39
- "@linxiraos/pi-catalog": "1.1.27",
40
- "@linxiraos/pi-natives": "1.1.27",
41
- "@linxiraos/pi-utils": "1.1.27",
42
- "@linxiraos/pi-wire": "1.1.27",
43
- "@linxiraos/pi-snapcompact": "1.1.27",
38
+ "@linxiraos/pi-ai": "1.2.0",
39
+ "@linxiraos/pi-catalog": "1.2.0",
40
+ "@linxiraos/pi-natives": "1.2.0",
41
+ "@linxiraos/pi-utils": "1.2.0",
42
+ "@linxiraos/pi-wire": "1.2.0",
43
+ "@linxiraos/pi-snapcompact": "1.2.0",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@linxiraos/pi-omptype": "1.1.27",
47
+ "@linxiraos/pi-omptype": "1.2.0",
48
48
  "@opentelemetry/context-async-hooks": "^2.9.0",
49
49
  "@opentelemetry/sdk-trace-base": "^2.9.0",
50
50
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -23,7 +23,6 @@ import {
23
23
  type TSchema,
24
24
  toolWireSchema,
25
25
  type UserMessage,
26
- validateToolArguments,
27
26
  } from "@linxiraos/pi-ai";
28
27
  import {
29
28
  type Dialect,
@@ -76,6 +75,7 @@ import {
76
75
  startExecuteToolSpan,
77
76
  startInvokeAgentSpan,
78
77
  } from "./telemetry";
78
+ import { validateAgentToolArguments } from "./tool-arguments";
79
79
  import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
80
80
  import dsmlToolCallLeakPrompt from "./prompts/dsml-tool-call-leak.md" with { type: "text" };
81
81
  import type {
@@ -3062,15 +3062,9 @@ async function prepareToolCallDispatch(
3062
3062
  if (!tool) {
3063
3063
  throw new Error(formatToolNotFoundMessage(toolCall.name, context.tools, suggestFallbackToolNames?.()));
3064
3064
  }
3065
- return validateToolArguments(tool, { ...toolCall, arguments: args });
3065
+ return validateAgentToolArguments(tool, { ...toolCall, arguments: args });
3066
3066
  } catch (validationError) {
3067
- // Lenience covers schema mismatches; a parse failure has no args to hand over.
3068
3067
  const parseFailed = "__parseError" in args;
3069
- if (tool?.lenientArgValidation && !parseFailed) {
3070
- const fallback = { ...args };
3071
- delete fallback.__rawJson;
3072
- return fallback;
3073
- }
3074
3068
  entry.args = parseFailed ? { __parseError: args.__parseError } : args;
3075
3069
  entry.validationErrorMessage =
3076
3070
  validationError instanceof Error ? validationError.message : String(validationError);
@@ -3210,6 +3204,7 @@ async function executeToolCalls(
3210
3204
  hasSteeringMessages,
3211
3205
  hasIrcInterrupts,
3212
3206
  hasBackgroundCompletions,
3207
+ hasQueuedAsides,
3213
3208
  interruptMode = "immediate",
3214
3209
  getToolContext,
3215
3210
 
@@ -3311,11 +3306,12 @@ async function executeToolCalls(
3311
3306
  // (only when soft interrupts are enabled; otherwise nothing is left).
3312
3307
  if (signal?.aborted) return;
3313
3308
  if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
3314
- // Peer IRC and background completions (finished jobs, exited supervised
3315
- // processes) hard-abort interruptible waits only; foreground tools keep
3316
- // running (no partial side effects).
3309
+ // Peer IRC, queued asides and background completions (finished jobs,
3310
+ // exited supervised processes) hard-abort interruptible waits only;
3311
+ // foreground tools keep running (no partial side effects).
3317
3312
  let source: AsideInterruptSource | undefined;
3318
3313
  if (hasIrcInterrupts && (await hasIrcInterrupts())) source = "irc";
3314
+ else if (!interruptState.triggered && hasQueuedAsides && (await hasQueuedAsides())) source = "aside";
3319
3315
  else if (!interruptState.triggered && hasBackgroundCompletions && (await hasBackgroundCompletions()))
3320
3316
  source = "background";
3321
3317
  if (!source) return;
@@ -3332,7 +3328,7 @@ async function executeToolCalls(
3332
3328
  // also cascades: the freshly detached job's own completion re-triggers
3333
3329
  // this check for the next command, so millisecond-long commands chain
3334
3330
  // into separate background deliveries (#12869).
3335
- if (source !== "background" && softInterrupts) steeringSoftController.abort();
3331
+ if (source === "irc" && softInterrupts) steeringSoftController.abort();
3336
3332
  };
3337
3333
 
3338
3334
  const checkSteering = async (): Promise<void> => {
@@ -3703,7 +3699,8 @@ async function executeToolCalls(
3703
3699
  // (auto-background bash), so the boundary dequeue below injects the message
3704
3700
  // promptly. In "wait" mode only a batch holding an interruptible wait needs
3705
3701
  // the watch; checkSteering is idempotent (no-op once triggered).
3706
- const hasAsidePeek = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
3702
+ const hasAsidePeek =
3703
+ hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined || hasQueuedAsides !== undefined;
3707
3704
  const watchSteeringWhileRunning =
3708
3705
  (softInterrupts || records.some(record => record.interruptible)) &&
3709
3706
  (hasSteeringMessages !== undefined || hasAsidePeek);
@@ -3989,8 +3986,8 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
3989
3986
  };
3990
3987
  }
3991
3988
 
3992
- /** Origin of a mid-batch interrupt: queued steering, a peer IRC, or a background completion notice. */
3993
- type AsideInterruptSource = SteeringInterruptSource | "irc" | "background";
3989
+ /** Origin of a mid-batch interrupt: queued steering, a peer IRC, a queued aside, or a background completion notice. */
3990
+ type AsideInterruptSource = SteeringInterruptSource | "irc" | "aside" | "background";
3994
3991
 
3995
3992
  function createSkippedToolResult(
3996
3993
  source: AsideInterruptSource | undefined,
@@ -4010,6 +4007,9 @@ function createSkippedToolResult(
4010
4007
  } else if (source === "irc") {
4011
4008
  reason = "pending peer interrupt";
4012
4009
  blocker = "interrupt";
4010
+ } else if (source === "aside") {
4011
+ reason = "a queued aside message";
4012
+ blocker = "aside";
4013
4013
  } else if (source === "background") {
4014
4014
  reason = "a queued background completion (job or supervised process)";
4015
4015
  blocker = "completion notice";
package/src/agent.ts CHANGED
@@ -453,6 +453,7 @@ export class Agent {
453
453
  #promptCacheKey?: string;
454
454
  #metadata?: Record<string, unknown>;
455
455
  #metadataResolver?: (provider: string) => Record<string, unknown> | undefined;
456
+ #modelResolver?: (model: Model) => Model;
456
457
  #providerSessionState?: Map<string, ProviderSessionState>;
457
458
  #thinkingBudgets?: ThinkingBudgets;
458
459
  #temperature?: number;
@@ -539,6 +540,11 @@ export class Agent {
539
540
  * are queued for the next boundary.
540
541
  */
541
542
  hasBackgroundCompletions?: AgentLoopConfig["hasBackgroundCompletions"];
543
+ /**
544
+ * Hook that peeks whether a passive aside is queued for the next boundary;
545
+ * ends an interruptible wait without interrupting other tools.
546
+ */
547
+ hasQueuedAsides?: AgentLoopConfig["hasQueuedAsides"];
542
548
 
543
549
  constructor(opts: AgentOptions = {}) {
544
550
  this.#state = { ...this.#state, ...opts.initialState };
@@ -933,7 +939,8 @@ export class Agent {
933
939
  }
934
940
 
935
941
  /** Register a listener notified after any steering/follow-up queue mutator
936
- * (enqueue, dequeue-on-delivery, clear, restore) runs. Internal-only signal —
942
+ * (enqueue, dequeue-on-delivery, clear, restore, live-steering adoption,
943
+ * withdrawal, and landing in the transcript) runs. Internal-only signal —
937
944
  * the queue itself has no concept of display filtering — so listeners
938
945
  * recompute their own snapshot from `peekSteeringQueue()`/`peekFollowUpQueue()`
939
946
  * (or a higher-level view) on notification. */
@@ -1066,11 +1073,13 @@ export class Agent {
1066
1073
 
1067
1074
  /** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
1068
1075
  #adoptLiveSteering(taken: readonly AgentMessage[]): void {
1076
+ let adopted = false;
1069
1077
  for (const delivery of this.#queuedMessageDeliveries) {
1070
1078
  const pending = delivery.messages.slice(delivery.next);
1071
1079
  const kept = pending.filter(message => !taken.includes(message));
1072
1080
  if (kept.length === pending.length) continue;
1073
1081
  let last: LiveSteeredEntry | undefined;
1082
+ adopted = true;
1074
1083
  for (const message of pending) {
1075
1084
  if (!taken.includes(message)) continue;
1076
1085
  last = { message, controller: delivery.controller };
@@ -1085,6 +1094,10 @@ export class Agent {
1085
1094
  delivery.next = 0;
1086
1095
  }
1087
1096
  }
1097
+ // The adopted messages move into #liveSteered, which queue snapshots
1098
+ // (peekLiveSteeredMessages) count as still pending; listeners must hear
1099
+ // about the swap or their queue view goes stale for the rest of the turn.
1100
+ if (adopted) this.#emitQueueChanged();
1088
1101
  }
1089
1102
 
1090
1103
  /**
@@ -1103,6 +1116,9 @@ export class Agent {
1103
1116
  suppress(message, controller);
1104
1117
  for (const context of additional ?? []) suppress(context, controller);
1105
1118
  }
1119
+ // Every snapshot counts live-steered messages as pending, so clearing
1120
+ // them is a queue change even when the caller re-queues them right after.
1121
+ const hadLiveSteered = this.#liveSteered.length > 0;
1106
1122
  this.#liveSteered = [];
1107
1123
  for (const delivery of this.#queuedMessageDeliveries) {
1108
1124
  const pending = delivery.messages.slice(delivery.next);
@@ -1110,6 +1126,7 @@ export class Agent {
1110
1126
  for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
1111
1127
  }
1112
1128
  this.#queuedMessageDeliveries.clear();
1129
+ if (hadLiveSteered) this.#emitQueueChanged();
1113
1130
  return withdrawn;
1114
1131
  }
1115
1132
 
@@ -1174,7 +1191,8 @@ export class Agent {
1174
1191
  /**
1175
1192
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
1176
1193
  * completions, late LSP diagnostics) drained at each step boundary. Never
1177
- * aborts in-flight tools. See `AgentLoopConfig.getAsideMessages`.
1194
+ * aborts foreground tools; an interruptible `wait` may end early through the
1195
+ * peek hooks. See `AgentLoopConfig.getAsideMessages`.
1178
1196
  */
1179
1197
  setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void {
1180
1198
  this.#asideMessageProvider = fn;
@@ -1207,8 +1225,20 @@ export class Agent {
1207
1225
  }
1208
1226
 
1209
1227
  setModel(model: Model) {
1210
- this.#state.model = model;
1211
- this.#syncTokenizer(model);
1228
+ // Sessions may start with no model selected (`initialState.model: undefined`).
1229
+ const resolved = this.#modelResolver && model ? this.#modelResolver(model) : model;
1230
+ this.#state.model = resolved;
1231
+ this.#syncTokenizer(resolved);
1232
+ }
1233
+
1234
+ /**
1235
+ * Route every model this agent adopts through `resolver` (e.g. to fit a
1236
+ * shared catalog row to this agent's own settings), starting with the
1237
+ * current one; `undefined` adopts models as given from now on.
1238
+ */
1239
+ setModelResolver(resolver: ((model: Model) => Model) | undefined): void {
1240
+ this.#modelResolver = resolver;
1241
+ if (resolver && this.#state.model) this.setModel(this.#state.model);
1212
1242
  }
1213
1243
 
1214
1244
  setThinkingLevel(l: Effort | undefined) {
@@ -1319,6 +1349,9 @@ export class Agent {
1319
1349
  const live = this.#liveSteered.findIndex(entry => entry.message === m);
1320
1350
  if (live >= 0) {
1321
1351
  this.#liveSteered.splice(live, 1);
1352
+ // The message landed in the transcript, so it stops counting as
1353
+ // queued pending input; snapshots shrink and listeners must hear it.
1354
+ this.#emitQueueChanged();
1322
1355
  return;
1323
1356
  }
1324
1357
  for (const delivery of this.#queuedMessageDeliveries) {
@@ -1913,6 +1946,7 @@ export class Agent {
1913
1946
  onLiveSteeringTaken: messages => this.#adoptLiveSteering(messages),
1914
1947
  hasIrcInterrupts: this.hasIrcInterrupts,
1915
1948
  hasBackgroundCompletions: this.hasBackgroundCompletions,
1949
+ hasQueuedAsides: this.hasQueuedAsides,
1916
1950
  getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
1917
1951
  getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1918
1952
  onBeforeYield: () => this.#onBeforeYield?.(),
@@ -2118,8 +2152,19 @@ export class Agent {
2118
2152
  turnOpen = false;
2119
2153
  this.#emit({ type: "agent_end", messages: agentEndMessages });
2120
2154
  } else {
2155
+ // Context hooks can reject on abort before provider streaming starts.
2156
+ // Publish the boundary just like a streaming abort so subscribers can
2157
+ // persist it and recover the interrupted turn after a reload.
2158
+ if (!turnOpen) this.#emit({ type: "turn_start" });
2159
+ if (!hadAssistantStart) {
2160
+ this.#state.streamMessage = errorMsg;
2161
+ this.#emit({ type: "message_start", message: errorMsg });
2162
+ }
2163
+ this.#state.streamMessage = null;
2121
2164
  this.appendMessage(errorMsg);
2122
2165
  this.#state.error = errorMessage;
2166
+ this.#emit({ type: "message_end", message: errorMsg });
2167
+ this.#emit({ type: "turn_end", message: errorMsg, toolResults: [] });
2123
2168
  this.#emit({ type: "agent_end", messages: [errorMsg] });
2124
2169
  }
2125
2170
  } finally {
@@ -112,21 +112,23 @@ export class StablePrefix {
112
112
  * Build or rebuild from live context.
113
113
  * Returns `true` if the prefix actually changed (cache miss imminent).
114
114
  *
115
- * Steady-state fast path: when the live prompt reference is unchanged
116
- * AND every tool resolves to the same normalized parameters identity as
117
- * last build, the fingerprint cannot have changed, so the full snapshot
118
- * + stringify is skipped. Comparing resolved parameters (not the tool
115
+ * Steady-state fast path: when the live prompt has the same segments as
116
+ * last build AND every tool resolves to the same normalized parameters
117
+ * identity, the fingerprint cannot have changed, so the full snapshot +
118
+ * stringify is skipped. Comparing resolved parameters (not the tool
119
119
  * container) is load-bearing: tools like ReadTool expose `parameters` as
120
120
  * a getter over live settings (`skillful`, `memory.backend`), so the
121
121
  * schema can swap under stable tool references when a setting toggles.
122
122
  * Any other in-place mutation must go through `invalidate()`.
123
123
  */
124
- #lastPrompt: readonly string[] | undefined;
125
- // Joined prompt bytes snapshot: the prompt array is caller-owned and
124
+ // Segment-level prompt copy: the prompt array is caller-owned and
126
125
  // mutable in place (Agent.setSystemPrompt stores the caller's array;
127
126
  // anyone holding it can push/splice), so reference equality alone cannot
128
- // prove the bytes are unchanged. Compared by value on the fast path.
129
- #lastPromptText: string | undefined;
127
+ // prove the bytes are unchanged. Comparing the recorded segments against
128
+ // the live ones roots out that mutation without concatenating the whole
129
+ // prompt just to compare it — segments are immutable strings, so `!==`
130
+ // on two strings is a byte comparison.
131
+ #lastPromptSegments: readonly string[] | undefined;
130
132
  // Per-tool wire-identity snapshot: every field normalizeTools and
131
133
  // computeFingerprint read (name, description, resolved parameters,
132
134
  // strict, customFormat, customWireName, intent mode, examples
@@ -138,11 +140,17 @@ export class StablePrefix {
138
140
 
139
141
  build(context: AgentContext, options: BuildOptions): boolean {
140
142
  const prev = this.#snapshot;
141
- if (prev !== null && this.#fastPathHit(context, options)) {
143
+ // The per-tool keys are rendered once per build and thread from the
144
+ // fast-path check into the recorded key, so a miss does not re-render
145
+ // every live tool description a second time (`normalizeTools` reads
146
+ // the same getters inside takeSnapshot). `prev === null` skips the
147
+ // check entirely — there is nothing to compare against.
148
+ const toolKeys = (context.tools ?? []).map(tool => toolKeyForPrefix(tool));
149
+ if (prev !== null && this.#fastPathHit(context, options, toolKeys)) {
142
150
  return false;
143
151
  }
144
152
  const snapshot = takeSnapshot(context, options);
145
- this.#recordFastPathKey(context, options, snapshot.tools);
153
+ this.#recordFastPathKey(context, options, toolKeys);
146
154
  if (prev && prev.fingerprint === snapshot.fingerprint) {
147
155
  // Identity changed but bytes did not (e.g. equivalent rebuild):
148
156
  // keep serving the cached snapshot so downstream memo identity
@@ -154,47 +162,48 @@ export class StablePrefix {
154
162
  return true;
155
163
  }
156
164
 
157
- /** True when the cheap key matches: prompt + per-tool resolved identity. */
158
- #fastPathHit(context: AgentContext, options: BuildOptions): boolean {
165
+ /** True when the cheap key matches: prompt segments + per-tool identity. */
166
+ #fastPathHit(context: AgentContext, options: BuildOptions, toolKeys: readonly string[]): boolean {
159
167
  if (
160
168
  this.#lastIntentTracing !== options.intentTracing ||
161
169
  this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
162
170
  ) {
163
171
  return false;
164
172
  }
165
- // Prompt by reference first (steady state), then by joined bytes so
166
- // an in-place push/splice of the same array still misses.
167
- if (this.#lastPrompt !== context.systemPrompt) return false;
168
- if (this.#lastPromptText !== undefined) {
169
- const text = context.systemPrompt.join("\u0000");
170
- if (text !== this.#lastPromptText) return false;
173
+ // Prompt segments: the caller may push/splice the live array in place
174
+ // without any reference change, so compare the recorded segments
175
+ // rather than concatenating the whole prompt just to compare it.
176
+ const liveSegments = context.systemPrompt;
177
+ const cachedSegments = this.#lastPromptSegments;
178
+ if (cachedSegments === undefined || cachedSegments.length !== liveSegments.length) {
179
+ return false;
180
+ }
181
+ for (let i = 0; i < cachedSegments.length; i++) {
182
+ if (cachedSegments[i] !== liveSegments[i]) return false;
171
183
  }
172
- const tools = context.tools ?? [];
173
- if (this.#lastToolKey === undefined || this.#lastToolKey.length !== tools.length) {
184
+ const recorded = this.#lastToolKey;
185
+ if (recorded === undefined || recorded.length !== toolKeys.length) {
174
186
  return false;
175
187
  }
176
- for (let i = 0; i < tools.length; i++) {
177
- if (this.#lastToolKey[i] !== toolKeyForPrefix(tools[i]!)) {
188
+ for (let i = 0; i < toolKeys.length; i++) {
189
+ if (recorded[i] !== toolKeys[i]) {
178
190
  return false;
179
191
  }
180
192
  }
181
193
  return true;
182
194
  }
183
195
 
184
- #recordFastPathKey(context: AgentContext, options: BuildOptions, normalized: Tool[]): void {
185
- this.#lastPrompt = context.systemPrompt;
186
- this.#lastPromptText = context.systemPrompt.join("\u0000");
196
+ #recordFastPathKey(context: AgentContext, options: BuildOptions, renderedToolKeys: readonly string[]): void {
197
+ this.#lastPromptSegments = [...context.systemPrompt];
187
198
  this.#lastIntentTracing = options.intentTracing;
188
199
  this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
189
- this.#lastToolKey = (context.tools ?? []).map(tool => toolKeyForPrefix(tool));
190
- void normalized;
200
+ this.#lastToolKey = renderedToolKeys;
191
201
  }
192
202
 
193
203
  /** Force rebuild on the next `build()` call. */
194
204
  invalidate(): void {
195
205
  this.#snapshot = null;
196
- this.#lastPrompt = undefined;
197
- this.#lastPromptText = undefined;
206
+ this.#lastPromptSegments = undefined;
198
207
  this.#lastToolKey = undefined;
199
208
  this.#lastIntentTracing = undefined;
200
209
  this.#lastPruneToolDescriptions = undefined;
@@ -86,7 +86,7 @@ export interface GenerateBranchSummaryOptions {
86
86
  convertToLlm?: ConvertToLlm;
87
87
  /**
88
88
  * Optional telemetry handle. When provided, the branch summary LLM call is
89
- * wrapped in an OTEL chat span tagged with `omp.gen_ai.oneshot.kind = "branch_summary"`.
89
+ * wrapped in an OTEL chat span tagged with `zeta.gen_ai.oneshot.kind = "branch_summary"`.
90
90
  */
91
91
  telemetry?: AgentTelemetry;
92
92
  /**
@@ -412,7 +412,16 @@ function buildCompactionV2Headers(
412
412
  : {
413
413
  "content-type": "application/json",
414
414
  ...resolveOpenAIRequestSetup(
415
- { provider: model.provider, id: model.id, baseUrl: model.baseUrl, headers: model.headers },
415
+ {
416
+ provider: model.provider,
417
+ id: model.id,
418
+ baseUrl: model.baseUrl,
419
+ headers: model.headers,
420
+ compat: {
421
+ sessionHeader:
422
+ model.compat && "sessionHeader" in model.compat ? model.compat.sessionHeader : undefined,
423
+ },
424
+ },
416
425
  { apiKey, messages: [], sessionId: request.sessionId ?? routingSessionId, promptCacheSessionId },
417
426
  ).headers,
418
427
  };