@linxiraos/pi-agent-core 1.1.27 → 1.2.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/CHANGELOG.md +6 -264
- package/README.md +1 -1
- package/dist/types/agent.d.ts +15 -2
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction.d.ts +10 -2
- package/dist/types/compaction/messages.d.ts +6 -0
- package/dist/types/compaction/pruning.d.ts +31 -7
- package/dist/types/compaction/tool-protection.d.ts +2 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +1 -1
- package/dist/types/telemetry.d.ts +60 -60
- package/dist/types/tool-arguments.d.ts +19 -0
- package/dist/types/types.d.ts +12 -1
- package/package.json +9 -9
- package/src/agent-loop.ts +15 -15
- package/src/agent.ts +49 -4
- package/src/append-only-context.ts +38 -29
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +10 -1
- package/src/compaction/compaction.ts +36 -42
- package/src/compaction/message-cache.ts +1 -1
- package/src/compaction/messages.ts +25 -0
- package/src/compaction/openai.ts +2 -0
- package/src/compaction/pruning.ts +249 -77
- package/src/compaction/tool-protection.ts +8 -2
- package/src/index.ts +2 -0
- package/src/speculative-execution.ts +5 -9
- package/src/telemetry.ts +81 -80
- package/src/tool-arguments.ts +38 -0
- package/src/types.ts +12 -1
|
@@ -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 `
|
|
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 = "
|
|
83
|
-
AgentStepCount = "
|
|
84
|
-
RequestReasoningEffort = "
|
|
85
|
-
RequestToolChoice = "
|
|
86
|
-
RequestAvailableTools = "
|
|
87
|
-
RequestMessages = "
|
|
88
|
-
ResponseText = "
|
|
89
|
-
ResponseToolCalls = "
|
|
90
|
-
ResponseUpstreamProvider = "
|
|
91
|
-
UsageTotalTokens = "
|
|
92
|
-
UsageServerSideTools = "
|
|
93
|
-
CostEstimatedUsd = "
|
|
94
|
-
CostInputUsd = "
|
|
95
|
-
CostOutputUsd = "
|
|
96
|
-
CostUnavailableReason = "
|
|
97
|
-
ToolStatus = "
|
|
98
|
-
ToolCallIntent = "
|
|
99
|
-
HandoffFromAgentName = "
|
|
100
|
-
HandoffFromAgentId = "
|
|
101
|
-
HandoffToAgentName = "
|
|
102
|
-
HandoffToAgentId = "
|
|
103
|
-
OneshotKind = "
|
|
104
|
-
GatewayName = "
|
|
105
|
-
GatewayEndpoint = "
|
|
106
|
-
GatewayCallId = "
|
|
107
|
-
GatewayRoutedTo = "
|
|
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 = "
|
|
109
|
+
GatewayResponseCacheStatus = "zeta.gen_ai.gateway.response_cache.status",
|
|
110
110
|
/** Caller-level reason a judgment ran (`find`, `ttsr`, `judge_batch`, …). */
|
|
111
|
-
JudgmentPurpose = "
|
|
111
|
+
JudgmentPurpose = "zeta.gen_ai.judgment.purpose",
|
|
112
112
|
/** Questions the caller asked in one judgment request. */
|
|
113
|
-
JudgmentQuestions = "
|
|
113
|
+
JudgmentQuestions = "zeta.gen_ai.judgment.questions",
|
|
114
114
|
/** Questions answered from the local judgment cache instead of the provider. */
|
|
115
|
-
JudgmentCachedQuestions = "
|
|
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
|
|
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
|
-
*
|
|
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 `
|
|
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 `
|
|
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
|
|
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 `
|
|
626
|
+
/** Aggregate `zeta.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
|
|
627
627
|
export declare const enum OmpGenAIAggregateAttr {
|
|
628
|
-
ChatsCount = "
|
|
629
|
-
ChatsTotalLatencyMs = "
|
|
630
|
-
ChatsStopReasonPrefix = "
|
|
631
|
-
ToolsCount = "
|
|
632
|
-
ToolsOkCount = "
|
|
633
|
-
ToolsErrorCount = "
|
|
634
|
-
ToolsSkippedCount = "
|
|
635
|
-
ToolsBlockedCount = "
|
|
636
|
-
ToolsTimeoutCount = "
|
|
637
|
-
ToolsAbortedCount = "
|
|
638
|
-
ToolsTotalLatencyMs = "
|
|
639
|
-
ToolsInvoked = "
|
|
640
|
-
ToolsAvailable = "
|
|
641
|
-
ToolsUnused = "
|
|
642
|
-
UsageInputTokensTotal = "
|
|
643
|
-
UsageOutputTokensTotal = "
|
|
644
|
-
UsageCacheReadInputTokensTotal = "
|
|
645
|
-
UsageCacheCreationInputTokensTotal = "
|
|
646
|
-
UsageReasoningOutputTokensTotal = "
|
|
647
|
-
UsageTotalTokensTotal = "
|
|
648
|
-
CostEstimatedUsdTotal = "
|
|
649
|
-
ErrorsCount = "
|
|
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"];
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
4
|
+
"version": "1.2.1",
|
|
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,19 +35,19 @@
|
|
|
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
|
|
39
|
-
"@linxiraos/pi-catalog": "1.1
|
|
40
|
-
"@linxiraos/pi-natives": "1.1
|
|
41
|
-
"@linxiraos/pi-utils": "1.1
|
|
42
|
-
"@linxiraos/pi-wire": "1.1
|
|
43
|
-
"@linxiraos/pi-snapcompact": "1.1
|
|
38
|
+
"@linxiraos/pi-ai": "1.2.1",
|
|
39
|
+
"@linxiraos/pi-catalog": "1.2.1",
|
|
40
|
+
"@linxiraos/pi-natives": "1.2.1",
|
|
41
|
+
"@linxiraos/pi-utils": "1.2.1",
|
|
42
|
+
"@linxiraos/pi-wire": "1.2.1",
|
|
43
|
+
"@linxiraos/pi-snapcompact": "1.2.1",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@linxiraos/pi-omptype": "1.1
|
|
47
|
+
"@linxiraos/pi-omptype": "1.2.1",
|
|
48
48
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
49
49
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
50
|
-
"@types/bun": "
|
|
50
|
+
"@types/bun": "1.4.3"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
|
53
53
|
"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
|
|
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,
|
|
3315
|
-
// processes) hard-abort interruptible waits only;
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
1211
|
-
this.#
|
|
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
|
|
116
|
-
* AND every tool resolves to the same normalized parameters
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
|
|
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.
|
|
129
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
173
|
-
if (
|
|
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 <
|
|
177
|
-
if (
|
|
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,
|
|
185
|
-
this.#
|
|
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 =
|
|
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.#
|
|
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 `
|
|
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
|
-
{
|
|
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
|
};
|