jeopi-agent-core 16.4.2 → 16.4.4

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.4.3] - 2026-07-22
6
+
7
+ ### Fixed
8
+
9
+ - Fixed skipped sibling tool results caused by system advisor steering so they no longer claim a queued user message caused the skip. `hasSteeringMessages` may now return a `SteeringQueueState` (`{ queued, source }`) instead of a plain boolean; the skipped-tool-result wording distinguishes queued user messages, pending system advisories, and peer IRC interrupts. Ported from oh-my-pi (upstream `74c63fa6c`).
10
+
5
11
  ## [16.2.30] - 2026-07-10
6
12
 
7
13
  ### Fixed
@@ -50,8 +50,11 @@ export declare function upsertFileOperations(summary: string, readFiles: string[
50
50
  */
51
51
  export declare function truncateToolResultForSummary(text: string): string;
52
52
  /**
53
- * Serialize LLM messages to text for summarization.
54
- * This prevents the model from treating it as a conversation to continue.
53
+ * Serialize LLM messages as plain summary input without provider control tokens.
54
+ */
55
+ export declare function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string;
56
+ /**
57
+ * Serialize LLM messages to transcript text.
55
58
  * Call convertToLlm() first to handle custom message types.
56
59
  */
57
60
  export declare function serializeConversation(messages: Message[], dialect?: Dialect): string;
@@ -53,6 +53,15 @@ export interface SoftToolRequirement {
53
53
  export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
54
54
  /** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
55
55
  export declare function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement;
56
+ /** Source category for a queued steering interrupt observed without consuming the queue. */
57
+ export type SteeringInterruptSource = "user" | "system" | "unknown";
58
+ /** Non-consuming summary of whether queued steering should interrupt a tool batch. */
59
+ export interface SteeringQueueState {
60
+ /** True when at least one steering message is queued. */
61
+ queued: boolean;
62
+ /** Best-effort origin used only to word synthetic skipped-tool results. */
63
+ source?: SteeringInterruptSource;
64
+ }
56
65
  /**
57
66
  * Configuration for the agent loop.
58
67
  */
@@ -156,8 +165,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
156
165
  *
157
166
  * When omitted, steering never interrupts a running tool batch; queued
158
167
  * messages are still delivered at the next injection boundary.
168
+ *
169
+ * Returning `true` is treated as user-originated steering for compatibility.
170
+ * Return a {@link SteeringQueueState} when the queue can distinguish system
171
+ * advisories from real user messages.
159
172
  */
160
- hasSteeringMessages?: () => boolean | Promise<boolean>;
173
+ hasSteeringMessages?: () => boolean | SteeringQueueState | Promise<boolean | SteeringQueueState>;
161
174
  /**
162
175
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.
163
176
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-agent-core",
4
- "version": "16.4.2",
4
+ "version": "16.4.4",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "jeopi-ai": "16.4.2",
39
- "jeopi-catalog": "16.4.2",
40
- "jeopi-natives": "16.4.2",
41
- "jeopi-utils": "16.4.2",
42
- "jeopi-wire": "16.4.2",
43
- "jeopi-snapcompact": "16.4.2",
38
+ "jeopi-ai": "16.4.4",
39
+ "jeopi-catalog": "16.4.4",
40
+ "jeopi-natives": "16.4.4",
41
+ "jeopi-utils": "16.4.4",
42
+ "jeopi-wire": "16.4.4",
43
+ "jeopi-snapcompact": "16.4.4",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -66,6 +66,8 @@ import type {
66
66
  AgentToolResult,
67
67
  AgentTurnEndContext,
68
68
  AsideMessage,
69
+ SteeringInterruptSource,
70
+ SteeringQueueState,
69
71
  StreamFn,
70
72
  } from "./types";
71
73
  import { isSoftToolRequirement } from "./types";
@@ -1723,7 +1725,7 @@ async function executeToolCalls(
1723
1725
  const interruptibleSignal: AbortSignal = signal
1724
1726
  ? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
1725
1727
  : AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
1726
- const interruptState = { triggered: false };
1728
+ const interruptState: { triggered: boolean; source?: SteeringInterruptSource | "irc" } = { triggered: false };
1727
1729
 
1728
1730
  const records = toolCalls.map(toolCall => {
1729
1731
  // Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
@@ -1758,17 +1760,28 @@ async function executeToolCalls(
1758
1760
  // the injection boundary. Fall back to consuming steering only for older
1759
1761
  // integrations that never supplied a peek.
1760
1762
  let steeringQueued = false;
1763
+ let steeringSource: SteeringInterruptSource | undefined;
1761
1764
  if (hasSteeringMessages) {
1762
- steeringQueued = await hasSteeringMessages();
1765
+ const queuedState = await hasSteeringMessages();
1766
+ if (typeof queuedState === "boolean") {
1767
+ steeringQueued = queuedState;
1768
+ steeringSource = queuedState ? "user" : undefined;
1769
+ } else {
1770
+ const state: SteeringQueueState = queuedState;
1771
+ steeringQueued = state.queued;
1772
+ steeringSource = state.source ?? (state.queued ? "unknown" : undefined);
1773
+ }
1763
1774
  } else if (getSteeringMessages) {
1764
1775
  const msgs = await getSteeringMessages();
1765
1776
  steeringQueued = (msgs?.length ?? 0) > 0;
1777
+ steeringSource = steeringQueued ? "user" : undefined;
1766
1778
  }
1767
1779
  if (steeringQueued) {
1768
- // User steering upgrades an in-flight IRC interrupt: it aborts the
1780
+ // Queued steering upgrades an in-flight IRC interrupt: it aborts the
1769
1781
  // shared signal so foreground tools stop as they do for a user Esc.
1770
1782
  if (!steeringAbortController.signal.aborted) {
1771
1783
  interruptState.triggered = true;
1784
+ interruptState.source = steeringSource ?? "unknown";
1772
1785
  steeringAbortController.abort();
1773
1786
  }
1774
1787
  return;
@@ -1778,6 +1791,7 @@ async function executeToolCalls(
1778
1791
  // Peer IRC only aborts interruptible waits: a foreground bash / write
1779
1792
  // mid-execution keeps running so we never leave partial side effects.
1780
1793
  interruptState.triggered = true;
1794
+ interruptState.source = "irc";
1781
1795
  ircAbortController.abort();
1782
1796
  }
1783
1797
  };
@@ -2038,7 +2052,7 @@ async function executeToolCalls(
2038
2052
  // This tool's own signal fired AND it failed — it was cut off before producing
2039
2053
  // a usable result, so report it as skipped.
2040
2054
  record.skipped = true;
2041
- emitToolResult(record, createSkippedToolResult(), true);
2055
+ emitToolResult(record, createSkippedToolResult(interruptState.source), true);
2042
2056
  } else {
2043
2057
  // No interrupt on this signal, or the tool finished (successfully or with a
2044
2058
  // genuine error) before the interrupt landed. Keep its real result: a completed
@@ -2132,7 +2146,7 @@ async function executeToolCalls(
2132
2146
  toolName: record.toolCall.name,
2133
2147
  status: "skipped",
2134
2148
  });
2135
- emitToolResult(record, createSkippedToolResult(), true);
2149
+ emitToolResult(record, createSkippedToolResult(interruptState.source), true);
2136
2150
  }
2137
2151
  }
2138
2152
 
@@ -2201,12 +2215,24 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
2201
2215
  };
2202
2216
  }
2203
2217
 
2204
- function createSkippedToolResult(): AgentToolResult<any> {
2218
+ function createSkippedToolResult(source: SteeringInterruptSource | "irc" | undefined): AgentToolResult<any> {
2219
+ let reason = "pending steering message";
2220
+ let blocker = "queued message";
2221
+ if (source === "user") {
2222
+ reason = "queued user message";
2223
+ blocker = "queued message";
2224
+ } else if (source === "system") {
2225
+ reason = "pending system advisory";
2226
+ blocker = "advisory";
2227
+ } else if (source === "irc") {
2228
+ reason = "pending peer interrupt";
2229
+ blocker = "interrupt";
2230
+ }
2205
2231
  return {
2206
2232
  content: [
2207
2233
  {
2208
2234
  type: "text",
2209
- text: "Skipped due to queued user message. Do not count this skipped result as completed work or verification. After the queued message is handled on the next step, retry the skipped tool if it is still needed.",
2235
+ text: `Skipped due to ${reason}. Do not count this skipped result as completed work or verification. After the ${blocker} is handled on the next step, retry the skipped tool if it is still needed.`,
2210
2236
  },
2211
2237
  ],
2212
2238
  details: {},
package/src/agent.ts CHANGED
@@ -1185,7 +1185,19 @@ export class Agent {
1185
1185
  }
1186
1186
  return this.#dequeueSteeringMessages();
1187
1187
  },
1188
- hasSteeringMessages: () => this.#steeringQueue.length > 0,
1188
+ hasSteeringMessages: () => {
1189
+ if (this.#steeringQueue.length === 0) {
1190
+ return { queued: false };
1191
+ }
1192
+ for (const message of this.#steeringQueue) {
1193
+ const role = "role" in message ? message.role : undefined;
1194
+ const attribution = "attribution" in message ? message.attribution : undefined;
1195
+ if (role === "user" && attribution !== "agent") {
1196
+ return { queued: true, source: "user" };
1197
+ }
1198
+ }
1199
+ return { queued: true, source: "system" };
1200
+ },
1189
1201
  hasIrcInterrupts: this.hasIrcInterrupts,
1190
1202
  getFollowUpMessages: async () => this.#dequeueFollowUpMessages(),
1191
1203
  getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
@@ -27,7 +27,7 @@ import {
27
27
  extractFileOpsFromMessage,
28
28
  type FileOperations,
29
29
  SUMMARIZATION_SYSTEM_PROMPT,
30
- serializeConversation,
30
+ serializeConversationForSummary,
31
31
  stripReadSelector,
32
32
  truncateToolResultForSummary,
33
33
  upsertFileOperations,
@@ -320,7 +320,7 @@ export async function generateBranchSummary(
320
320
  // Transform to LLM-compatible messages, then serialize to text
321
321
  // Serialization prevents the model from treating it as a conversation to continue
322
322
  const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
323
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
323
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
324
324
 
325
325
  // Build prompt
326
326
  const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
@@ -63,7 +63,7 @@ import {
63
63
  extractFileOpsFromMessage,
64
64
  type FileOperations,
65
65
  SUMMARIZATION_SYSTEM_PROMPT,
66
- serializeConversation,
66
+ serializeConversationForSummary,
67
67
  stripReadSelector,
68
68
  upsertFileOperations,
69
69
  } from "./utils";
@@ -786,7 +786,7 @@ export async function generateSummary(
786
786
  // Serialize conversation to text so model doesn't try to continue it
787
787
  // Convert to LLM messages first (handles custom app messages when caller provides a transformer).
788
788
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
789
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
789
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
790
790
 
791
791
  // Build the prompt with conversation wrapped in tags
792
792
  let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
@@ -983,7 +983,7 @@ async function generateShortSummary(
983
983
  ): Promise<string> {
984
984
  const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
985
985
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
986
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
986
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
987
987
 
988
988
  let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
989
989
  if (historySummary) {
@@ -1518,7 +1518,7 @@ async function generateTurnPrefixSummary(
1518
1518
  const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
1519
1519
 
1520
1520
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
1521
- const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
1521
+ const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
1522
1522
  const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
1523
1523
  const summarizationMessages = [
1524
1524
  {
@@ -207,9 +207,19 @@ export function truncateToolResultForSummary(text: string): string {
207
207
  return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
208
208
  }
209
209
 
210
+ const HARMONY_CONTROL_TOKEN_RE = /<\|(start|end|message|channel|constrain|return|call)\|>/g;
211
+
212
+ /**
213
+ * Serialize LLM messages as plain summary input without provider control tokens.
214
+ */
215
+ export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
216
+ const conversation = serializeConversation(messages, dialect);
217
+ if (dialect !== "harmony") return conversation;
218
+ return conversation.replace(HARMONY_CONTROL_TOKEN_RE, "<\\|$1\\|>");
219
+ }
220
+
210
221
  /**
211
- * Serialize LLM messages to text for summarization.
212
- * This prevents the model from treating it as a conversation to continue.
222
+ * Serialize LLM messages to transcript text.
213
223
  * Call convertToLlm() first to handle custom message types.
214
224
  */
215
225
  export function serializeConversation(messages: Message[], dialect?: Dialect): string {
package/src/types.ts CHANGED
@@ -83,6 +83,17 @@ export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined
83
83
  return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true;
84
84
  }
85
85
 
86
+ /** Source category for a queued steering interrupt observed without consuming the queue. */
87
+ export type SteeringInterruptSource = "user" | "system" | "unknown";
88
+
89
+ /** Non-consuming summary of whether queued steering should interrupt a tool batch. */
90
+ export interface SteeringQueueState {
91
+ /** True when at least one steering message is queued. */
92
+ queued: boolean;
93
+ /** Best-effort origin used only to word synthetic skipped-tool results. */
94
+ source?: SteeringInterruptSource;
95
+ }
96
+
86
97
  /**
87
98
  * Configuration for the agent loop.
88
99
  */
@@ -196,8 +207,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
196
207
  *
197
208
  * When omitted, steering never interrupts a running tool batch; queued
198
209
  * messages are still delivered at the next injection boundary.
210
+ *
211
+ * Returning `true` is treated as user-originated steering for compatibility.
212
+ * Return a {@link SteeringQueueState} when the queue can distinguish system
213
+ * advisories from real user messages.
199
214
  */
200
- hasSteeringMessages?: () => boolean | Promise<boolean>;
215
+ hasSteeringMessages?: () => boolean | SteeringQueueState | Promise<boolean | SteeringQueueState>;
201
216
 
202
217
  /**
203
218
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.