@oh-my-pi/pi-agent-core 17.0.0 → 17.0.2

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.2] - 2026-07-17
6
+
7
+ ### Fixed
8
+
9
+ - Improved error visibility in interactive clients by surfacing provider stream failures through the assistant message lifecycle, preventing silent loading spinners.
10
+ - Fixed an issue where Cursor provider contexts omitted host-supplied MCP tools from main and side-channel requests.
11
+
5
12
  ## [17.0.0] - 2026-07-15
6
13
 
7
14
  ### Breaking Changes
@@ -2,7 +2,7 @@
2
2
  * Agent loop that works with AgentMessage throughout.
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
- import { type Context, EventStream } from "@oh-my-pi/pi-ai";
5
+ import { type AssistantMessage, type Context, EventStream, type ToolResultMessage } from "@oh-my-pi/pi-ai";
6
6
  import { type Dialect } from "@oh-my-pi/pi-ai/dialect";
7
7
  import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector.js";
8
8
  import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
@@ -111,3 +111,10 @@ export interface SyntheticToolResultDetails {
111
111
  executed: false;
112
112
  upstreamError?: string;
113
113
  }
114
+ /**
115
+ * Create the persisted synthetic result for a tool call that was emitted by
116
+ * the assistant but never invoked locally.
117
+ */
118
+ export declare function createSyntheticToolResultMessage(toolCall: Extract<AssistantMessage["content"][number], {
119
+ type: "toolCall";
120
+ }>, reason: "aborted" | "error" | "skipped" | "length", errorMessage?: string): ToolResultMessage<SyntheticToolResultDetails>;
@@ -157,6 +157,8 @@ export interface AgentOptions {
157
157
  * Cursor exec handlers for local tool execution.
158
158
  */
159
159
  cursorExecHandlers?: CursorExecHandlers;
160
+ /** Additional tools Cursor executes through its MCP request-context bridge, resolved before each provider call. */
161
+ getCursorTools?: () => AgentTool[];
160
162
  /**
161
163
  * Cursor tool result callback for exec tool responses.
162
164
  */
@@ -2,7 +2,7 @@
2
2
  * Proxy stream function for apps that route LLM calls through a server.
3
3
  * The server manages auth and proxies requests to LLM providers.
4
4
  */
5
- import { type AssistantMessage, type AssistantMessageEvent, type Context, EventStream, type FetchImpl, type Model, type SimpleStreamOptions, type StopReason } from "@oh-my-pi/pi-ai";
5
+ import { type AssistantMessage, type AssistantMessageEvent, type Context, EventStream, type FetchImpl, type ImageContent, type Model, type SimpleStreamOptions, type StopReason } from "@oh-my-pi/pi-ai";
6
6
  export declare class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
7
7
  constructor();
8
8
  }
@@ -33,6 +33,10 @@ export type ProxyAssistantMessageEvent = {
33
33
  type: "thinking_end";
34
34
  contentIndex: number;
35
35
  contentSignature?: string;
36
+ } | {
37
+ type: "image_end";
38
+ contentIndex: number;
39
+ content: ImageContent;
36
40
  } | {
37
41
  type: "toolcall_start";
38
42
  contentIndex: number;
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": "17.0.0",
4
+ "version": "17.0.2",
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,17 +35,17 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "17.0.0",
39
- "@oh-my-pi/pi-catalog": "17.0.0",
40
- "@oh-my-pi/pi-natives": "17.0.0",
41
- "@oh-my-pi/pi-utils": "17.0.0",
42
- "@oh-my-pi/pi-wire": "17.0.0",
43
- "@oh-my-pi/snapcompact": "17.0.0",
38
+ "@oh-my-pi/pi-ai": "17.0.2",
39
+ "@oh-my-pi/pi-catalog": "17.0.2",
40
+ "@oh-my-pi/pi-natives": "17.0.2",
41
+ "@oh-my-pi/pi-utils": "17.0.2",
42
+ "@oh-my-pi/pi-wire": "17.0.2",
43
+ "@oh-my-pi/snapcompact": "17.0.2",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@opentelemetry/context-async-hooks": "^2.7.1",
48
- "@opentelemetry/sdk-trace-base": "^2.7.1",
47
+ "@opentelemetry/context-async-hooks": "^2.9.0",
48
+ "@opentelemetry/sdk-trace-base": "^2.9.0",
49
49
  "@types/bun": "^1.3.14"
50
50
  },
51
51
  "engines": {
package/src/agent-loop.ts CHANGED
@@ -183,6 +183,7 @@ type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall"
183
183
  function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
184
184
  switch (block.type) {
185
185
  case "text":
186
+ case "image":
186
187
  return { ...block };
187
188
  case "thinking":
188
189
  return { ...block };
@@ -224,6 +225,7 @@ function snapshotAssistantMessageEvent(
224
225
  case "text_start":
225
226
  case "text_delta":
226
227
  case "text_end":
228
+ case "image_end":
227
229
  case "thinking_start":
228
230
  case "thinking_delta":
229
231
  case "thinking_end":
@@ -1525,6 +1527,7 @@ async function streamAssistantResponse(
1525
1527
  case "text_start":
1526
1528
  case "text_delta":
1527
1529
  case "text_end":
1530
+ case "image_end":
1528
1531
  case "thinking_start":
1529
1532
  case "thinking_delta":
1530
1533
  case "thinking_end":
@@ -2289,18 +2292,14 @@ function syntheticDetailsFor(
2289
2292
  }
2290
2293
 
2291
2294
  /**
2292
- * Create a tool result for a tool call that was emitted by the assistant but
2293
- * never invoked locally. Maintains the tool_use / tool_result pairing the
2294
- * provider API requires, and tags {@link SyntheticToolResultDetails} so
2295
- * consumers can distinguish this from a real local tool failure without
2296
- * string-matching the content (#4321).
2295
+ * Create the persisted synthetic result for a tool call that was emitted by
2296
+ * the assistant but never invoked locally.
2297
2297
  */
2298
- function createAbortedToolResult(
2298
+ export function createSyntheticToolResultMessage(
2299
2299
  toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
2300
- stream: EventStream<AgentEvent, AgentMessage[]>,
2301
2300
  reason: "aborted" | "error" | "skipped" | "length",
2302
2301
  errorMessage?: string,
2303
- ): ToolResultMessage {
2302
+ ): ToolResultMessage<SyntheticToolResultDetails> {
2304
2303
  const message =
2305
2304
  reason === "aborted"
2306
2305
  ? "Tool execution was aborted"
@@ -2310,9 +2309,31 @@ function createAbortedToolResult(
2310
2309
  ? "Tool call was not executed because the assistant ended its turn"
2311
2310
  : "Tool call was not executed because the provider stream ended with an error before the tool could run";
2312
2311
  const details = syntheticDetailsFor(reason, errorMessage);
2313
- const result: AgentToolResult<SyntheticToolResultDetails> = {
2312
+ return {
2313
+ role: "toolResult",
2314
+ toolCallId: toolCall.id,
2315
+ toolName: toolCall.name,
2314
2316
  content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
2315
2317
  details,
2318
+ isError: true,
2319
+ timestamp: Date.now(),
2320
+ };
2321
+ }
2322
+
2323
+ /**
2324
+ * Create and emit a tool result for a tool call that was emitted by the
2325
+ * assistant but never invoked locally.
2326
+ */
2327
+ function createAbortedToolResult(
2328
+ toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
2329
+ stream: EventStream<AgentEvent, AgentMessage[]>,
2330
+ reason: "aborted" | "error" | "skipped" | "length",
2331
+ errorMessage?: string,
2332
+ ): ToolResultMessage {
2333
+ const toolResultMessage = createSyntheticToolResultMessage(toolCall, reason, errorMessage);
2334
+ const result: AgentToolResult<SyntheticToolResultDetails> = {
2335
+ content: toolResultMessage.content,
2336
+ details: toolResultMessage.details,
2316
2337
  };
2317
2338
 
2318
2339
  stream.push({
@@ -2329,17 +2350,6 @@ function createAbortedToolResult(
2329
2350
  result,
2330
2351
  isError: true,
2331
2352
  });
2332
-
2333
- const toolResultMessage: ToolResultMessage<SyntheticToolResultDetails> = {
2334
- role: "toolResult",
2335
- toolCallId: toolCall.id,
2336
- toolName: toolCall.name,
2337
- content: result.content,
2338
- details,
2339
- isError: true,
2340
- timestamp: Date.now(),
2341
- };
2342
-
2343
2353
  stream.push({ type: "message_start", message: toolResultMessage });
2344
2354
  stream.push({ type: "message_end", message: toolResultMessage });
2345
2355
 
package/src/agent.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  abortReasonText,
32
32
  agentLoop,
33
33
  agentLoopContinue,
34
+ createSyntheticToolResultMessage,
34
35
  normalizeMessagesForProvider,
35
36
  normalizeTools,
36
37
  resolveOwnedDialectFromEnv,
@@ -64,12 +65,6 @@ function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
64
65
  });
65
66
  }
66
67
 
67
- const ANTHROPIC_OUTPUT_BLOCKED_PREFIX = "Output blocked by conten";
68
-
69
- function isAnthropicOutputBlockedError(message: string): boolean {
70
- return message.includes(ANTHROPIC_OUTPUT_BLOCKED_PREFIX);
71
- }
72
-
73
68
  function refreshToolChoiceForActiveTools(
74
69
  toolChoice: ToolChoice | undefined,
75
70
  tools: AgentContext["tools"] = [],
@@ -267,6 +262,8 @@ export interface AgentOptions {
267
262
  * Cursor exec handlers for local tool execution.
268
263
  */
269
264
  cursorExecHandlers?: CursorExecHandlers;
265
+ /** Additional tools Cursor executes through its MCP request-context bridge, resolved before each provider call. */
266
+ getCursorTools?: () => AgentTool[];
270
267
 
271
268
  /**
272
269
  * Cursor tool result callback for exec tool responses.
@@ -368,6 +365,7 @@ export class Agent {
368
365
  #maxRetryDelayMs?: number;
369
366
  #getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
370
367
  #cursorExecHandlers?: CursorExecHandlers;
368
+ #getCursorTools?: () => AgentTool[];
371
369
  #cursorOnToolResult?: CursorToolResultHandler;
372
370
  #cwd?: string;
373
371
  #cwdResolver?: () => string | undefined;
@@ -450,6 +448,7 @@ export class Agent {
450
448
  this.#onSseEvent = opts.onSseEvent;
451
449
  this.#getToolContext = opts.getToolContext;
452
450
  this.#cursorExecHandlers = opts.cursorExecHandlers;
451
+ this.#getCursorTools = opts.getCursorTools;
453
452
  this.#cursorOnToolResult = opts.cursorOnToolResult;
454
453
  this.#cwd = opts.cwd;
455
454
  this.#cwdResolver = opts.cwdResolver;
@@ -694,6 +693,22 @@ export class Agent {
694
693
  this.#appendOnlyContext = manager;
695
694
  }
696
695
 
696
+ #toolsForModel(model: Model): AgentTool[] {
697
+ if (model.api !== "cursor-agent" || !this.#getCursorTools) return this.#state.tools;
698
+ const cursorTools = this.#getCursorTools();
699
+ if (cursorTools.length === 0) return this.#state.tools;
700
+
701
+ const names = new Set(this.#state.tools.map(tool => tool.name));
702
+ let merged: AgentTool[] | undefined;
703
+ for (const tool of cursorTools) {
704
+ if (names.has(tool.name)) continue;
705
+ merged ??= this.#state.tools.slice();
706
+ merged.push(tool);
707
+ names.add(tool.name);
708
+ }
709
+ return merged ?? this.#state.tools;
710
+ }
711
+
697
712
  /**
698
713
  * Assemble the provider Context for a side-channel (no-loop) request, mirroring
699
714
  * the main loop's prefix (system + normalized tools) so it shares the prompt
@@ -718,7 +733,7 @@ export class Agent {
718
733
  const tools = ownedDialect
719
734
  ? []
720
735
  : (normalizeTools(
721
- this.#state.tools,
736
+ this.#toolsForModel(model),
722
737
  this.#intentTracing,
723
738
  preferredDialect(model.id),
724
739
  this.#pruneToolDescriptions,
@@ -1152,7 +1167,7 @@ export class Agent {
1152
1167
  await Bun.sleep(0);
1153
1168
  }
1154
1169
  context.systemPrompt = this.#state.systemPrompt;
1155
- context.tools = this.#state.tools;
1170
+ context.tools = this.#toolsForModel(this.#state.model ?? model);
1156
1171
  },
1157
1172
  cursorExecHandlers: this.#cursorExecHandlers,
1158
1173
  cursorOnToolResult,
@@ -1205,6 +1220,7 @@ export class Agent {
1205
1220
  };
1206
1221
 
1207
1222
  let partial: AgentMessage | null = null;
1223
+ const completedToolCallIds = new Set<string>();
1208
1224
 
1209
1225
  try {
1210
1226
  const stream = messages
@@ -1222,6 +1238,9 @@ export class Agent {
1222
1238
  case "message_update":
1223
1239
  partial = event.message;
1224
1240
  this.#state.streamMessage = event.message;
1241
+ if (event.assistantMessageEvent.type === "toolcall_end") {
1242
+ completedToolCallIds.add(event.assistantMessageEvent.toolCall.id);
1243
+ }
1225
1244
  break;
1226
1245
 
1227
1246
  case "message_end":
@@ -1283,12 +1302,22 @@ export class Agent {
1283
1302
  : err instanceof Error
1284
1303
  ? err.message
1285
1304
  : String(err);
1286
- const shouldEmitVisibleOutputBlockedError = !stoppedForAbort && isAnthropicOutputBlockedError(errorMessage);
1305
+ const shouldEmitVisibleError = !stoppedForAbort;
1287
1306
  const assistantPartial = partial?.role === "assistant" ? partial : undefined;
1288
1307
  const hadAssistantStart = assistantPartial !== undefined;
1308
+ const bufferedCursorResults = this.#cursorToolResultBuffer.map(({ toolResult }) => toolResult);
1309
+ const retainedToolCallIds = new Set(completedToolCallIds);
1310
+ for (const { toolCallId } of bufferedCursorResults) retainedToolCallIds.add(toolCallId);
1289
1311
  const errorMsg: AssistantMessage =
1290
- shouldEmitVisibleOutputBlockedError && assistantPartial
1291
- ? { ...assistantPartial, stopReason: "error", errorMessage }
1312
+ shouldEmitVisibleError && assistantPartial
1313
+ ? {
1314
+ ...assistantPartial,
1315
+ content: assistantPartial.content.filter(
1316
+ block => block.type !== "toolCall" || retainedToolCallIds.has(block.id),
1317
+ ),
1318
+ stopReason: "error",
1319
+ errorMessage,
1320
+ }
1292
1321
  : {
1293
1322
  role: "assistant",
1294
1323
  content: [{ type: "text", text: "" }],
@@ -1308,7 +1337,7 @@ export class Agent {
1308
1337
  timestamp: Date.now(),
1309
1338
  };
1310
1339
 
1311
- if (shouldEmitVisibleOutputBlockedError) {
1340
+ if (shouldEmitVisibleError) {
1312
1341
  if (!hadAssistantStart) {
1313
1342
  this.#state.streamMessage = errorMsg;
1314
1343
  this.#emit({ type: "message_start", message: errorMsg });
@@ -1317,8 +1346,40 @@ export class Agent {
1317
1346
  this.appendMessage(errorMsg);
1318
1347
  this.#state.error = errorMessage;
1319
1348
  this.#emit({ type: "message_end", message: errorMsg });
1320
- this.#emit({ type: "turn_end", message: errorMsg, toolResults: [] });
1321
- this.#emit({ type: "agent_end", messages: [errorMsg] });
1349
+ const toolResults: ToolResultMessage[] = [];
1350
+ this.#cursorToolResultBuffer = [];
1351
+ const bufferedCursorToolCallIds = new Set(bufferedCursorResults.map(({ toolCallId }) => toolCallId));
1352
+ for (const toolResult of bufferedCursorResults) {
1353
+ this.appendMessage(toolResult);
1354
+ this.#emit({ type: "message_start", message: toolResult });
1355
+ this.#emit({ type: "message_end", message: toolResult });
1356
+ toolResults.push(toolResult);
1357
+ }
1358
+ for (const block of errorMsg.content) {
1359
+ if (block.type !== "toolCall") continue;
1360
+ if (bufferedCursorToolCallIds.has(block.id)) continue;
1361
+ const toolResult = createSyntheticToolResultMessage(block, "error", errorMessage);
1362
+ this.#emit({
1363
+ type: "tool_execution_start",
1364
+ toolCallId: block.id,
1365
+ toolName: block.name,
1366
+ args: block.arguments,
1367
+ intent: block.intent,
1368
+ });
1369
+ this.#emit({
1370
+ type: "tool_execution_end",
1371
+ toolCallId: block.id,
1372
+ toolName: block.name,
1373
+ result: { content: toolResult.content, details: toolResult.details },
1374
+ isError: true,
1375
+ });
1376
+ this.appendMessage(toolResult);
1377
+ this.#emit({ type: "message_start", message: toolResult });
1378
+ this.#emit({ type: "message_end", message: toolResult });
1379
+ toolResults.push(toolResult);
1380
+ }
1381
+ this.#emit({ type: "turn_end", message: errorMsg, toolResults });
1382
+ this.#emit({ type: "agent_end", messages: [errorMsg, ...toolResults] });
1322
1383
  } else {
1323
1384
  this.appendMessage(errorMsg);
1324
1385
  this.#state.error = errorMessage;
package/src/proxy.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type Context,
9
9
  EventStream,
10
10
  type FetchImpl,
11
+ type ImageContent,
11
12
  type Model,
12
13
  type SimpleStreamOptions,
13
14
  type StopReason,
@@ -47,6 +48,7 @@ export type ProxyAssistantMessageEvent =
47
48
  | { type: "thinking_start"; contentIndex: number }
48
49
  | { type: "thinking_delta"; contentIndex: number; delta: string }
49
50
  | { type: "thinking_end"; contentIndex: number; contentSignature?: string }
51
+ | { type: "image_end"; contentIndex: number; content: ImageContent }
50
52
  | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
51
53
  | { type: "toolcall_delta"; contentIndex: number; delta: string }
52
54
  | { type: "toolcall_end"; contentIndex: number }
@@ -315,6 +317,15 @@ function processProxyEvent(
315
317
  throw new Error("Received thinking_end for non-thinking content");
316
318
  }
317
319
 
320
+ case "image_end":
321
+ partial.content[proxyEvent.contentIndex] = proxyEvent.content;
322
+ return {
323
+ type: "image_end",
324
+ contentIndex: proxyEvent.contentIndex,
325
+ content: proxyEvent.content,
326
+ partial,
327
+ };
328
+
318
329
  case "toolcall_start":
319
330
  partial.content[proxyEvent.contentIndex] = {
320
331
  type: "toolCall",
package/src/telemetry.ts CHANGED
@@ -957,6 +957,9 @@ function assistantContentToOtelParts(content: AssistantMessage["content"]): Otel
957
957
  case "text":
958
958
  parts.push({ type: "text", content: part.text });
959
959
  break;
960
+ case "image":
961
+ parts.push({ type: "blob", modality: "image", mime_type: part.mimeType, content: part.data });
962
+ break;
960
963
  case "thinking":
961
964
  parts.push({ type: "reasoning", content: part.thinking });
962
965
  break;