@oh-my-pi/pi-agent-core 16.3.0 → 16.3.3
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 +12 -0
- package/dist/types/agent-loop.d.ts +23 -0
- package/dist/types/types.d.ts +8 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +100 -25
- package/src/agent.ts +21 -104
- package/src/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.3] - 2026-07-02
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Enabled dynamic model resolution to support seamless mid-run model switching.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed an issue in the Cursor agent where assistant messages containing native tool calls could duplicate text blocks on replay.
|
|
14
|
+
- Fixed a bug where Cursor agent exec-channel tools (such as bash, write, and delete) were executed a second time after server-side execution.
|
|
15
|
+
- Improved error handling for tool calls interrupted by upstream provider stream errors, distinguishing transport/provider failures from local tool execution failures in the CLI, events, and messages.
|
|
16
|
+
|
|
5
17
|
## [16.3.0] - 2026-07-02
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -64,3 +64,26 @@ export declare function normalizeTools(tools: AgentContext["tools"], injectInten
|
|
|
64
64
|
* `signal.reason` is the default `AbortError` `DOMException`) falls back to the
|
|
65
65
|
* generic sentinel that downstream renderers treat as "no specific reason". */
|
|
66
66
|
export declare function abortReasonText(signal: AbortSignal | undefined): string;
|
|
67
|
+
/**
|
|
68
|
+
* Discriminator embedded in {@link AgentToolResult.details} and
|
|
69
|
+
* {@link ToolResultMessage.details} for tool calls that were emitted by the
|
|
70
|
+
* assistant but never actually invoked locally.
|
|
71
|
+
*
|
|
72
|
+
* The synthetic result exists only to preserve the tool_use / tool_result
|
|
73
|
+
* pairing the provider API requires; no `tool.execute()` ran. UI, telemetry,
|
|
74
|
+
* and history consumers can key on `__synthetic === true` to render or
|
|
75
|
+
* classify these as "call emitted, not executed" instead of a real local
|
|
76
|
+
* tool failure — the mislabeling this discriminator was introduced to fix
|
|
77
|
+
* (#4321): a provider-side stream error after tool-call emission (e.g. Codex
|
|
78
|
+
* websocket close) was surfaced by the CLI as if the local tool had failed.
|
|
79
|
+
*
|
|
80
|
+
* `source` names the assistant-side termination state that prevented
|
|
81
|
+
* execution; `upstreamError` is the provider-reported message when the turn
|
|
82
|
+
* ended with `stopReason === "error"`.
|
|
83
|
+
*/
|
|
84
|
+
export interface SyntheticToolResultDetails {
|
|
85
|
+
__synthetic: true;
|
|
86
|
+
source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
|
|
87
|
+
executed: false;
|
|
88
|
+
upstreamError?: string;
|
|
89
|
+
}
|
package/dist/types/types.d.ts
CHANGED
|
@@ -274,6 +274,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
274
274
|
* the next model call instead of waiting for the next prompt.
|
|
275
275
|
*/
|
|
276
276
|
getReasoning?: () => Effort | undefined;
|
|
277
|
+
/**
|
|
278
|
+
* Dynamic model override, resolved once per LLM call. When set, each
|
|
279
|
+
* provider call re-reads the model (like {@link getReasoning}) so mid-run
|
|
280
|
+
* model switches — context promotion, retry fallback — apply on the next
|
|
281
|
+
* call instead of the run finishing on the stale model captured at
|
|
282
|
+
* run-loop start. Falls back to the static {@link model} when unset.
|
|
283
|
+
*/
|
|
284
|
+
getModel?: () => Model;
|
|
277
285
|
/**
|
|
278
286
|
* Dynamic reasoning-disable override, resolved per LLM call. When set,
|
|
279
287
|
* its return value overrides the static `disableReasoning` from
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.3",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "16.3.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.3.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.3.
|
|
38
|
+
"@oh-my-pi/pi-ai": "16.3.3",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "16.3.3",
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.3.3",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.3.3",
|
|
42
|
+
"@oh-my-pi/pi-wire": "16.3.3",
|
|
43
|
+
"@oh-my-pi/snapcompact": "16.3.3",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
wrapInbandToolStream,
|
|
27
27
|
} from "@oh-my-pi/pi-ai/dialect";
|
|
28
28
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
29
|
+
import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
|
|
29
30
|
import {
|
|
30
31
|
createHarmonyAuditEvent,
|
|
31
32
|
detectHarmonyLeakInAssistantMessage,
|
|
@@ -910,7 +911,13 @@ async function runLoopBody(
|
|
|
910
911
|
// Create placeholder tool results for any tool calls in the aborted message
|
|
911
912
|
// This maintains the tool_use/tool_result pairing that the API requires
|
|
912
913
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
913
|
-
|
|
914
|
+
// Cursor exec-resolved blocks already have their toolResult buffered
|
|
915
|
+
// for out-of-band emission; a placeholder aborted result here would
|
|
916
|
+
// pair a duplicate to the same toolCallId (issue #4348 codex review).
|
|
917
|
+
const toolCalls = message.content.filter(
|
|
918
|
+
(c): c is ToolCallContent =>
|
|
919
|
+
c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
920
|
+
);
|
|
914
921
|
const toolResults: ToolResultMessage[] = [];
|
|
915
922
|
for (const toolCall of toolCalls) {
|
|
916
923
|
const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
|
|
@@ -949,7 +956,15 @@ async function runLoopBody(
|
|
|
949
956
|
// trailing tool_use may be truncated with incomplete arguments — those calls
|
|
950
957
|
// are abandoned below. (`error`/`aborted` already returned above.)
|
|
951
958
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
952
|
-
|
|
959
|
+
// A Cursor exec-channel synthesized `toolCall` block carries
|
|
960
|
+
// `kCursorExecResolved` because Cursor already executed the tool
|
|
961
|
+
// server-side (via the bridge) and buffered the result for
|
|
962
|
+
// out-of-band emission — running it here again would duplicate the
|
|
963
|
+
// same side-effecting call (issue #4348 review by @chatgpt-codex-connector).
|
|
964
|
+
const toolCalls = message.content.filter(
|
|
965
|
+
(c): c is ToolCallContent =>
|
|
966
|
+
c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
967
|
+
);
|
|
953
968
|
const runnableStop = message.stopReason === "toolUse" || message.stopReason === "stop";
|
|
954
969
|
hasMoreToolCalls = runnableStop && toolCalls.length > 0;
|
|
955
970
|
|
|
@@ -1131,7 +1146,7 @@ async function emitHarmonyAudit(
|
|
|
1131
1146
|
createHarmonyAuditEvent({
|
|
1132
1147
|
action,
|
|
1133
1148
|
detection: interruption.detection,
|
|
1134
|
-
model: config.model,
|
|
1149
|
+
model: config.getModel?.() ?? config.model,
|
|
1135
1150
|
retryN,
|
|
1136
1151
|
removed: interruption.removed,
|
|
1137
1152
|
}),
|
|
@@ -1155,6 +1170,11 @@ async function streamAssistantResponse(
|
|
|
1155
1170
|
hostToolChoice?: ToolChoice,
|
|
1156
1171
|
forcedToolChoice?: ToolChoice,
|
|
1157
1172
|
): Promise<AssistantMessage> {
|
|
1173
|
+
// Re-resolve the model per provider call (like `getReasoning`): mid-run
|
|
1174
|
+
// model switches — context promotion, retry fallback — must apply on the
|
|
1175
|
+
// next call instead of the run silently finishing on the stale model
|
|
1176
|
+
// captured at run start.
|
|
1177
|
+
const model = config.getModel?.() ?? config.model;
|
|
1158
1178
|
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
|
|
1159
1179
|
let messages = context.messages;
|
|
1160
1180
|
if (config.transformContext) {
|
|
@@ -1163,10 +1183,10 @@ async function streamAssistantResponse(
|
|
|
1163
1183
|
|
|
1164
1184
|
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
|
|
1165
1185
|
const llmMessages = await config.convertToLlm(messages);
|
|
1166
|
-
const normalizedMessages = normalizeMessagesForProvider(llmMessages,
|
|
1186
|
+
const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
|
|
1167
1187
|
|
|
1168
1188
|
const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
1169
|
-
const exampleDialect = ownedDialect ?? preferredDialect(
|
|
1189
|
+
const exampleDialect = ownedDialect ?? preferredDialect(model.id);
|
|
1170
1190
|
// Owned/in-band dialects carry the catalog in the prompt as text and send no
|
|
1171
1191
|
// native `tools`, so description pruning only applies to native tool calling.
|
|
1172
1192
|
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
|
|
@@ -1188,7 +1208,7 @@ async function streamAssistantResponse(
|
|
|
1188
1208
|
};
|
|
1189
1209
|
}
|
|
1190
1210
|
if (config.transformProviderContext) {
|
|
1191
|
-
llmContext = await config.transformProviderContext(llmContext,
|
|
1211
|
+
llmContext = await config.transformProviderContext(llmContext, model);
|
|
1192
1212
|
}
|
|
1193
1213
|
|
|
1194
1214
|
// Owned tool calling: take tool calls away from the provider and run them
|
|
@@ -1212,8 +1232,8 @@ async function streamAssistantResponse(
|
|
|
1212
1232
|
// `getServiceTier` is authoritative when present (replaces the static tier
|
|
1213
1233
|
// for both the wire request and telemetry), so callers can scope priority
|
|
1214
1234
|
// per model without touching the shared session `serviceTier`.
|
|
1215
|
-
const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(
|
|
1216
|
-
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(
|
|
1235
|
+
const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(model) : config.serviceTier;
|
|
1236
|
+
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(model);
|
|
1217
1237
|
const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
|
|
1218
1238
|
const requestSignal = harmonyAbortController
|
|
1219
1239
|
? signal
|
|
@@ -1235,13 +1255,13 @@ async function streamAssistantResponse(
|
|
|
1235
1255
|
: providerAbortSignals.length === 1
|
|
1236
1256
|
? providerAbortSignals[0]!
|
|
1237
1257
|
: AbortSignal.any(providerAbortSignals);
|
|
1238
|
-
const requestApiKey = (config.getApiKey ? await config.getApiKey(
|
|
1258
|
+
const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
|
|
1239
1259
|
const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
|
|
1240
1260
|
const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
|
|
1241
1261
|
|
|
1242
1262
|
// Re-resolve metadata after credential selection so the per-request value
|
|
1243
1263
|
// reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
|
|
1244
|
-
const resolvedMetadata = config.metadataResolver ? config.metadataResolver(
|
|
1264
|
+
const resolvedMetadata = config.metadataResolver ? config.metadataResolver(model.provider) : config.metadata;
|
|
1245
1265
|
const effectiveTemperature =
|
|
1246
1266
|
harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
|
|
1247
1267
|
// Owned tool calling sends no native tools, so any tool_choice would error.
|
|
@@ -1254,7 +1274,7 @@ async function streamAssistantResponse(
|
|
|
1254
1274
|
|
|
1255
1275
|
const chatStepNumber = stepCounter.count;
|
|
1256
1276
|
stepCounter.count += 1;
|
|
1257
|
-
const chatSpan = startChatSpan(telemetry,
|
|
1277
|
+
const chatSpan = startChatSpan(telemetry, model, {
|
|
1258
1278
|
parent: invokeAgentSpan,
|
|
1259
1279
|
stepNumber: chatStepNumber,
|
|
1260
1280
|
request: {
|
|
@@ -1287,13 +1307,13 @@ async function streamAssistantResponse(
|
|
|
1287
1307
|
stepNumber: chatStepNumber,
|
|
1288
1308
|
serviceTier: effectiveServiceTier,
|
|
1289
1309
|
responseHeaders: capturedHeaders,
|
|
1290
|
-
baseUrl:
|
|
1310
|
+
baseUrl: model.baseUrl,
|
|
1291
1311
|
});
|
|
1292
1312
|
};
|
|
1293
1313
|
|
|
1294
1314
|
try {
|
|
1295
1315
|
return await runInActiveSpan(chatSpan, async () => {
|
|
1296
|
-
let response = await streamFunction(
|
|
1316
|
+
let response = await streamFunction(model, llmContext, {
|
|
1297
1317
|
...config,
|
|
1298
1318
|
apiKey,
|
|
1299
1319
|
metadata: resolvedMetadata,
|
|
@@ -1513,7 +1533,7 @@ async function streamAssistantResponse(
|
|
|
1513
1533
|
failChatSpan(telemetry, chatSpan, {
|
|
1514
1534
|
errorObject: err,
|
|
1515
1535
|
responseHeaders: capturedHeaders,
|
|
1516
|
-
baseUrl:
|
|
1536
|
+
baseUrl: model.baseUrl,
|
|
1517
1537
|
});
|
|
1518
1538
|
throw err;
|
|
1519
1539
|
}
|
|
@@ -1614,6 +1634,7 @@ function emitAbortedAssistantMessage(
|
|
|
1614
1634
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
1615
1635
|
requestSignal: AbortSignal | undefined,
|
|
1616
1636
|
): AssistantMessage {
|
|
1637
|
+
const model = config.getModel?.() ?? config.model;
|
|
1617
1638
|
const errorMessage = abortReasonText(requestSignal);
|
|
1618
1639
|
const errorId =
|
|
1619
1640
|
errorMessage === "Request was aborted"
|
|
@@ -1624,9 +1645,9 @@ function emitAbortedAssistantMessage(
|
|
|
1624
1645
|
: {
|
|
1625
1646
|
role: "assistant",
|
|
1626
1647
|
content: [],
|
|
1627
|
-
api:
|
|
1628
|
-
provider:
|
|
1629
|
-
model:
|
|
1648
|
+
api: model.api,
|
|
1649
|
+
provider: model.provider,
|
|
1650
|
+
model: model.id,
|
|
1630
1651
|
usage: {
|
|
1631
1652
|
input: 0,
|
|
1632
1653
|
output: 0,
|
|
@@ -1679,7 +1700,13 @@ async function executeToolCalls(
|
|
|
1679
1700
|
afterToolCall,
|
|
1680
1701
|
} = config;
|
|
1681
1702
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
1682
|
-
|
|
1703
|
+
// Defensive: the outer loop already filters exec-resolved blocks before
|
|
1704
|
+
// deciding to invoke `executeToolCalls`, but skip them here too so the
|
|
1705
|
+
// guarantee lives with the code that would re-run the tool.
|
|
1706
|
+
const toolCalls = assistantMessage.content.filter(
|
|
1707
|
+
(c): c is ToolCallContent =>
|
|
1708
|
+
c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
1709
|
+
);
|
|
1683
1710
|
const emittedToolResults: ToolResultMessage[] = [];
|
|
1684
1711
|
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
|
|
1685
1712
|
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
|
|
@@ -2113,8 +2140,55 @@ async function executeToolCalls(
|
|
|
2113
2140
|
}
|
|
2114
2141
|
|
|
2115
2142
|
/**
|
|
2116
|
-
*
|
|
2117
|
-
*
|
|
2143
|
+
* Discriminator embedded in {@link AgentToolResult.details} and
|
|
2144
|
+
* {@link ToolResultMessage.details} for tool calls that were emitted by the
|
|
2145
|
+
* assistant but never actually invoked locally.
|
|
2146
|
+
*
|
|
2147
|
+
* The synthetic result exists only to preserve the tool_use / tool_result
|
|
2148
|
+
* pairing the provider API requires; no `tool.execute()` ran. UI, telemetry,
|
|
2149
|
+
* and history consumers can key on `__synthetic === true` to render or
|
|
2150
|
+
* classify these as "call emitted, not executed" instead of a real local
|
|
2151
|
+
* tool failure — the mislabeling this discriminator was introduced to fix
|
|
2152
|
+
* (#4321): a provider-side stream error after tool-call emission (e.g. Codex
|
|
2153
|
+
* websocket close) was surfaced by the CLI as if the local tool had failed.
|
|
2154
|
+
*
|
|
2155
|
+
* `source` names the assistant-side termination state that prevented
|
|
2156
|
+
* execution; `upstreamError` is the provider-reported message when the turn
|
|
2157
|
+
* ended with `stopReason === "error"`.
|
|
2158
|
+
*/
|
|
2159
|
+
export interface SyntheticToolResultDetails {
|
|
2160
|
+
__synthetic: true;
|
|
2161
|
+
source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
|
|
2162
|
+
executed: false;
|
|
2163
|
+
upstreamError?: string;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function syntheticDetailsFor(
|
|
2167
|
+
reason: "aborted" | "error" | "skipped" | "length",
|
|
2168
|
+
errorMessage: string | undefined,
|
|
2169
|
+
): SyntheticToolResultDetails {
|
|
2170
|
+
const source: SyntheticToolResultDetails["source"] =
|
|
2171
|
+
reason === "aborted"
|
|
2172
|
+
? "assistant_stop_aborted"
|
|
2173
|
+
: reason === "error"
|
|
2174
|
+
? "assistant_stop_error"
|
|
2175
|
+
: reason === "length"
|
|
2176
|
+
? "assistant_stop_length"
|
|
2177
|
+
: "assistant_stop_skipped";
|
|
2178
|
+
return {
|
|
2179
|
+
__synthetic: true,
|
|
2180
|
+
source,
|
|
2181
|
+
executed: false,
|
|
2182
|
+
...(reason === "error" && errorMessage ? { upstreamError: errorMessage } : {}),
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Create a tool result for a tool call that was emitted by the assistant but
|
|
2188
|
+
* never invoked locally. Maintains the tool_use / tool_result pairing the
|
|
2189
|
+
* provider API requires, and tags {@link SyntheticToolResultDetails} so
|
|
2190
|
+
* consumers can distinguish this from a real local tool failure without
|
|
2191
|
+
* string-matching the content (#4321).
|
|
2118
2192
|
*/
|
|
2119
2193
|
function createAbortedToolResult(
|
|
2120
2194
|
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
|
|
@@ -2129,10 +2203,11 @@ function createAbortedToolResult(
|
|
|
2129
2203
|
? "Tool call was not executed because the assistant hit its output token limit (stop_reason: length) before the arguments could complete; the recorded arguments are truncated and unsafe to run. Do NOT retry by re-emitting the same large payload — split the work into several smaller tool calls (e.g. for `write`/`edit`, write the first chunk then append the rest with subsequent `edit` insert ops, or break the file into multiple `write` targets)"
|
|
2130
2204
|
: reason === "skipped"
|
|
2131
2205
|
? "Tool call was not executed because the assistant ended its turn"
|
|
2132
|
-
: "Tool
|
|
2133
|
-
const
|
|
2206
|
+
: "Tool call was not executed because the provider stream ended with an error before the tool could run";
|
|
2207
|
+
const details = syntheticDetailsFor(reason, errorMessage);
|
|
2208
|
+
const result: AgentToolResult<SyntheticToolResultDetails> = {
|
|
2134
2209
|
content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
|
|
2135
|
-
details
|
|
2210
|
+
details,
|
|
2136
2211
|
};
|
|
2137
2212
|
|
|
2138
2213
|
stream.push({
|
|
@@ -2150,12 +2225,12 @@ function createAbortedToolResult(
|
|
|
2150
2225
|
isError: true,
|
|
2151
2226
|
});
|
|
2152
2227
|
|
|
2153
|
-
const toolResultMessage: ToolResultMessage = {
|
|
2228
|
+
const toolResultMessage: ToolResultMessage<SyntheticToolResultDetails> = {
|
|
2154
2229
|
role: "toolResult",
|
|
2155
2230
|
toolCallId: toolCall.id,
|
|
2156
2231
|
toolName: toolCall.name,
|
|
2157
2232
|
content: result.content,
|
|
2158
|
-
details
|
|
2233
|
+
details,
|
|
2159
2234
|
isError: true,
|
|
2160
2235
|
timestamp: Date.now(),
|
|
2161
2236
|
};
|
package/src/agent.ts
CHANGED
|
@@ -320,10 +320,9 @@ export interface AgentPromptOptions {
|
|
|
320
320
|
toolChoice?: ToolChoice;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
/** Buffered Cursor tool result
|
|
323
|
+
/** Buffered Cursor exec-channel tool result waiting to be emitted after the assistant message. */
|
|
324
324
|
interface CursorToolResultEntry {
|
|
325
325
|
toolResult: ToolResultMessage;
|
|
326
|
-
textLengthAtCall: number;
|
|
327
326
|
}
|
|
328
327
|
|
|
329
328
|
export class Agent {
|
|
@@ -1097,12 +1096,11 @@ export class Agent {
|
|
|
1097
1096
|
}
|
|
1098
1097
|
} catch {}
|
|
1099
1098
|
}
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
this.#cursorToolResultBuffer.push({ toolResult: finalMessage, textLengthAtCall: textLength });
|
|
1099
|
+
// Cursor executes tools server-side during streaming. We buffer
|
|
1100
|
+
// each toolResult and emit them right after the assistant message
|
|
1101
|
+
// closes (see `#emitCursorSplitAssistantMessage`), so replay
|
|
1102
|
+
// receives (assistant with interleaved toolCall blocks) → results.
|
|
1103
|
+
this.#cursorToolResultBuffer.push({ toolResult: finalMessage });
|
|
1106
1104
|
return finalMessage;
|
|
1107
1105
|
}
|
|
1108
1106
|
: undefined;
|
|
@@ -1175,6 +1173,7 @@ export class Agent {
|
|
|
1175
1173
|
onHarmonyLeak: this.#onHarmonyLeak,
|
|
1176
1174
|
onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
|
|
1177
1175
|
getToolChoice,
|
|
1176
|
+
getModel: () => this.#state.model ?? model,
|
|
1178
1177
|
getReasoning: () => this.#state.thinkingLevel,
|
|
1179
1178
|
getDisableReasoning: () => this.#state.disableReasoning,
|
|
1180
1179
|
getServiceTier: this.#serviceTierResolver,
|
|
@@ -1343,115 +1342,33 @@ export class Agent {
|
|
|
1343
1342
|
}
|
|
1344
1343
|
}
|
|
1345
1344
|
|
|
1346
|
-
/** Calculate total text length from an assistant message's content blocks */
|
|
1347
|
-
#getAssistantTextLength(message: AgentMessage | null): number {
|
|
1348
|
-
if (message?.role !== "assistant" || !Array.isArray(message.content)) {
|
|
1349
|
-
return 0;
|
|
1350
|
-
}
|
|
1351
|
-
let length = 0;
|
|
1352
|
-
for (const block of message.content) {
|
|
1353
|
-
if (block.type === "text") {
|
|
1354
|
-
length += (block as TextContent).text.length;
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
return length;
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
1345
|
/**
|
|
1361
|
-
* Emit a Cursor assistant message
|
|
1362
|
-
*
|
|
1346
|
+
* Emit a Cursor assistant message with buffered exec-channel toolResults.
|
|
1347
|
+
*
|
|
1348
|
+
* Since the Cursor provider now synthesizes `toolCall` content blocks at the
|
|
1349
|
+
* point each exec tool starts (issue #4348), the assistant message content
|
|
1350
|
+
* already interleaves text/thinking with toolCall blocks in execution order.
|
|
1351
|
+
* We emit the message as-is and let the buffered toolResults follow — the
|
|
1352
|
+
* transcript rebuild in `renderSessionContext` pairs them by `toolCallId`.
|
|
1363
1353
|
*
|
|
1364
|
-
*
|
|
1354
|
+
* Historical note: this used to split the assistant message at
|
|
1355
|
+
* `textLengthAtCall` to interpose toolResults between preamble and
|
|
1356
|
+
* continuation. That workaround existed because native cursor tools had no
|
|
1357
|
+
* toolCall blocks; it also copied `preambleText` into every text block on
|
|
1358
|
+
* multi-text turns, producing duplicated text on replay.
|
|
1365
1359
|
*/
|
|
1366
1360
|
#emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void {
|
|
1367
1361
|
const buffer = this.#cursorToolResultBuffer;
|
|
1368
1362
|
this.#cursorToolResultBuffer = [];
|
|
1369
1363
|
|
|
1370
|
-
if (buffer.length === 0) {
|
|
1371
|
-
// No tool results, emit normally
|
|
1372
|
-
this.#state.streamMessage = null;
|
|
1373
|
-
this.appendMessage(assistantMessage);
|
|
1374
|
-
this.#emit({ type: "message_end", message: assistantMessage });
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// Find the split point: minimum text length at first tool call
|
|
1379
|
-
const splitPoint = Math.min(...buffer.map(r => r.textLengthAtCall));
|
|
1380
|
-
|
|
1381
|
-
// Extract text content from assistant message
|
|
1382
|
-
const content = assistantMessage.content;
|
|
1383
|
-
let fullText = "";
|
|
1384
|
-
for (const block of content) {
|
|
1385
|
-
if (block.type === "text") {
|
|
1386
|
-
fullText += block.text;
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
// If no text or split point is 0 or at/past end, don't split
|
|
1391
|
-
if (fullText.length === 0 || splitPoint <= 0 || splitPoint >= fullText.length) {
|
|
1392
|
-
// Emit assistant message first, then tool results (original behavior but with buffered results)
|
|
1393
|
-
this.#state.streamMessage = null;
|
|
1394
|
-
this.appendMessage(assistantMessage);
|
|
1395
|
-
this.#emit({ type: "message_end", message: assistantMessage });
|
|
1396
|
-
|
|
1397
|
-
// Emit buffered tool results
|
|
1398
|
-
for (const { toolResult } of buffer) {
|
|
1399
|
-
this.#emit({ type: "message_start", message: toolResult });
|
|
1400
|
-
this.appendMessage(toolResult);
|
|
1401
|
-
this.#emit({ type: "message_end", message: toolResult });
|
|
1402
|
-
}
|
|
1403
|
-
return;
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
// Split the text
|
|
1407
|
-
const preambleText = fullText.slice(0, splitPoint);
|
|
1408
|
-
const continuationText = fullText.slice(splitPoint);
|
|
1409
|
-
|
|
1410
|
-
// Create preamble message (text before tools)
|
|
1411
|
-
const preambleContent = content.map(block => {
|
|
1412
|
-
if (block.type === "text") {
|
|
1413
|
-
return { ...block, text: preambleText };
|
|
1414
|
-
}
|
|
1415
|
-
return block;
|
|
1416
|
-
});
|
|
1417
|
-
const preambleMessage: AssistantMessage = {
|
|
1418
|
-
...assistantMessage,
|
|
1419
|
-
content: preambleContent,
|
|
1420
|
-
};
|
|
1421
|
-
|
|
1422
|
-
// Emit preamble
|
|
1423
1364
|
this.#state.streamMessage = null;
|
|
1424
|
-
this.appendMessage(
|
|
1425
|
-
this.#emit({ type: "message_end", message:
|
|
1365
|
+
this.appendMessage(assistantMessage);
|
|
1366
|
+
this.#emit({ type: "message_end", message: assistantMessage });
|
|
1426
1367
|
|
|
1427
|
-
// Emit buffered tool results
|
|
1428
1368
|
for (const { toolResult } of buffer) {
|
|
1429
1369
|
this.#emit({ type: "message_start", message: toolResult });
|
|
1430
1370
|
this.appendMessage(toolResult);
|
|
1431
1371
|
this.#emit({ type: "message_end", message: toolResult });
|
|
1432
1372
|
}
|
|
1433
|
-
|
|
1434
|
-
// Emit continuation message (text after tools) if non-empty
|
|
1435
|
-
const trimmedContinuation = continuationText.trim();
|
|
1436
|
-
if (trimmedContinuation.length > 0) {
|
|
1437
|
-
// Create continuation message with only text content (no thinking/toolCalls)
|
|
1438
|
-
const continuationContent: TextContent[] = [{ type: "text", text: continuationText }];
|
|
1439
|
-
const continuationMessage: AssistantMessage = {
|
|
1440
|
-
...assistantMessage,
|
|
1441
|
-
content: continuationContent,
|
|
1442
|
-
// Zero out usage for continuation since it's part of same response
|
|
1443
|
-
usage: {
|
|
1444
|
-
input: 0,
|
|
1445
|
-
output: 0,
|
|
1446
|
-
cacheRead: 0,
|
|
1447
|
-
cacheWrite: 0,
|
|
1448
|
-
totalTokens: 0,
|
|
1449
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
1450
|
-
},
|
|
1451
|
-
};
|
|
1452
|
-
this.#emit({ type: "message_start", message: continuationMessage });
|
|
1453
|
-
this.appendMessage(continuationMessage);
|
|
1454
|
-
this.#emit({ type: "message_end", message: continuationMessage });
|
|
1455
|
-
}
|
|
1456
1373
|
}
|
|
1457
1374
|
}
|
package/src/types.ts
CHANGED
|
@@ -324,6 +324,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
324
324
|
* the next model call instead of waiting for the next prompt.
|
|
325
325
|
*/
|
|
326
326
|
getReasoning?: () => Effort | undefined;
|
|
327
|
+
/**
|
|
328
|
+
* Dynamic model override, resolved once per LLM call. When set, each
|
|
329
|
+
* provider call re-reads the model (like {@link getReasoning}) so mid-run
|
|
330
|
+
* model switches — context promotion, retry fallback — apply on the next
|
|
331
|
+
* call instead of the run finishing on the stale model captured at
|
|
332
|
+
* run-loop start. Falls back to the static {@link model} when unset.
|
|
333
|
+
*/
|
|
334
|
+
getModel?: () => Model;
|
|
327
335
|
|
|
328
336
|
/**
|
|
329
337
|
* Dynamic reasoning-disable override, resolved per LLM call. When set,
|