@gajae-code/agent-core 0.16.7 → 0.17.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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.17.1] - 2026-09-17
6
+
7
+ ## [0.17.0] - 2026-09-17
8
+
9
+ ### Added
10
+
11
+ - Maintenance LLM calls (compaction, handoff, branch summaries) forward a `maintenanceCall: true` marker to the provider. Agent-level providers such as Devin over ACP use it to refuse work they cannot serve instead of forwarding a summarization prompt to a billed upstream agent.
12
+
13
+ ### Fixed
14
+
15
+ - Lossless, managed, abort-path, and safety-stop assistant-message rebuilds now carry the in-process provider-resolved tool-call marker across the boundary. The marker is a symbol, so a deep snapshot silently dropped it, and the loop would then dispatch a provider-executed tool call to a local tool of the same display name — or, on an aborted turn, fabricate a failed tool result for it. This affected Cursor's exec-owned calls and the new Devin ACP provider alike. The marker is registered with `Symbol.for` and restoration is fail-closed on ambiguous identity.
16
+
17
+ - Publish reasoning summaries, thinking, and tool-call updates during ordinary unmanaged streaming instead of holding them until text, response completion, or interruption. Tool execution still waits for terminal validation; already-published legacy guarded calls receive explicit rejection instead of silent resampling. Managed fallback attempts remain atomic.
18
+ - Provisional streaming consumers can reject an escaped-non-ASCII tool-call turn after earlier updates without publishing its terminal message, allowing AgentSession to resample the turn without persisting defective provider metadata.
19
+
5
20
  ## [0.16.7] - 2026-09-13
6
21
 
7
22
  ## [0.16.6] - 2026-09-07
@@ -378,7 +378,7 @@ export declare class Agent {
378
378
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void;
379
379
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
380
380
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
381
- setProvisionalAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
381
+ setProvisionalAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => boolean | undefined) | undefined): void;
382
382
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
383
383
  setShouldPause(fn: AgentLoopConfig["shouldPause"] | undefined): void;
384
384
  /** The currently installed cooperative pause checkpoint, if any. */
@@ -64,6 +64,12 @@ export interface GenerateBranchSummaryOptions {
64
64
  sessionId?: string;
65
65
  /** Opaque provider conversation identity; never derived from branch-summary content. */
66
66
  providerSessionId?: string;
67
+ /**
68
+ * Marks this as GJC maintenance work rather than an interactive user turn.
69
+ * Agent-level providers (e.g. `devin-acp`) refuse maintenance calls they
70
+ * cannot serve instead of forwarding them to a billed upstream agent.
71
+ */
72
+ maintenanceCall?: boolean;
67
73
  /** Shared provider state map so the branch summary call reuses session-scoped transport/session caches. */
68
74
  providerSessionState?: Map<string, ProviderSessionState>;
69
75
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
@@ -194,6 +194,12 @@ export interface SummaryOptions {
194
194
  remoteEndpoint?: string;
195
195
  remoteInstructions?: string;
196
196
  initiatorOverride?: MessageAttribution;
197
+ /**
198
+ * Marks this as GJC maintenance work rather than an interactive user turn.
199
+ * Agent-level providers (e.g. `devin-acp`) refuse maintenance calls they
200
+ * cannot serve instead of forwarding them to a billed upstream agent.
201
+ */
202
+ maintenanceCall?: boolean;
197
203
  metadata?: Record<string, unknown>;
198
204
  convertToLlm?: ConvertToLlm;
199
205
  /**
@@ -252,6 +258,12 @@ export interface HandoffOptions {
252
258
  promptExtension?: string;
253
259
  convertToLlm?: ConvertToLlm;
254
260
  initiatorOverride?: MessageAttribution;
261
+ /**
262
+ * Marks this as GJC maintenance work rather than an interactive user turn.
263
+ * Agent-level providers (e.g. `devin-acp`) refuse maintenance calls they
264
+ * cannot serve instead of forwarding them to a billed upstream agent.
265
+ */
266
+ maintenanceCall?: boolean;
255
267
  metadata?: Record<string, unknown>;
256
268
  /**
257
269
  * Optional telemetry handle. When provided, the handoff LLM call is
@@ -442,8 +442,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
442
442
  * Callers may abort synchronously to stop consuming buffered provider events.
443
443
  */
444
444
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
445
- /** Observe unmanaged provisional assistant deltas before public publication. */
446
- onProvisionalAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
445
+ /** Observe unmanaged provisional assistant deltas before public publication. Return false to reject the turn. */
446
+ onProvisionalAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => boolean | undefined;
447
447
  /** True when the host consumes provisional assistant events for live safety checks. */
448
448
  hasProvisionalAssistantMessageEventConsumer?: boolean;
449
449
  /** Called for non-content tool-choice incapability stream events. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.16.7",
4
+ "version": "0.17.1",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -32,9 +32,9 @@
32
32
  "fmt": "biome format --write ."
33
33
  },
34
34
  "dependencies": {
35
- "@gajae-code/ai": "0.16.7",
36
- "@gajae-code/natives": "0.16.7",
37
- "@gajae-code/utils": "0.16.7",
35
+ "@gajae-code/ai": "0.17.1",
36
+ "@gajae-code/natives": "0.17.1",
37
+ "@gajae-code/utils": "0.17.1",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  neutralizeReservedControlTokens,
35
35
  stripUnusableReasoningItems,
36
36
  } from "@gajae-code/ai/utils";
37
- import { isCursorExecResolved } from "@gajae-code/ai/utils/block-symbols";
37
+ import { copyProviderResolvedToolCall, isProviderResolvedToolCall } from "@gajae-code/ai/utils/block-symbols";
38
38
  import {
39
39
  attachUnicodeEscapeEvidence,
40
40
  type UnicodeEscapeEvidence,
@@ -815,7 +815,12 @@ function managedRetryableFailure(failure: unknown): boolean {
815
815
  // caller is not authorized to make, and the credential-mutating consumers
816
816
  // downstream would block healthy credentials on the way.
817
817
  if (trigger.class === "auth") return trigger.authDisposition !== "forbidden";
818
- return trigger.class === "rate_limit" || trigger.class === "quota" || trigger.class === "server";
818
+ return (
819
+ trigger.class === "rate_limit" ||
820
+ trigger.class === "quota" ||
821
+ trigger.class === "credential" ||
822
+ trigger.class === "server"
823
+ );
819
824
  }
820
825
 
821
826
  function promoteTypedEmptyResponseStop(message: AssistantMessage): void {
@@ -874,6 +879,7 @@ function sanitizeProviderSafetyStopProvenance(
874
879
  return repaired;
875
880
  }
876
881
  restoreTransientUnicodeEscapeEvidence(rebuilt.content, message);
882
+ restoreProviderResolvedMarkers(rebuilt.content, message);
877
883
  return rebuilt;
878
884
  }
879
885
  const rebuilt = managedAssistantShell(message, model);
@@ -2027,6 +2033,7 @@ function losslessDetachedClone<T>(value: T): T {
2027
2033
  "providerCode",
2028
2034
  "openaiErrorCode",
2029
2035
  "anthropicErrorType",
2036
+ "credentialModelUnavailable",
2030
2037
  "retryAfterMs",
2031
2038
  "headers",
2032
2039
  ] as const) {
@@ -2101,6 +2108,7 @@ function managedAssistantShell(
2101
2108
  }
2102
2109
  const content = rawArray === undefined ? [] : rawArray.flatMap(managedContentBlock);
2103
2110
  restoreTransientUnicodeEscapeEvidence(content, value);
2111
+ restoreProviderResolvedMarkers(content, value);
2104
2112
  const usage = managedAssistantUsage(managedAttemptSnapshot(managedProperty(source, "usage")));
2105
2113
  const api = managedProperty(source, "api");
2106
2114
  const provider = managedProperty(source, "provider");
@@ -2175,6 +2183,52 @@ function managedContentBlock(block: unknown): AssistantMessage["content"] {
2175
2183
  return normalized ? [normalized] : [];
2176
2184
  }
2177
2185
 
2186
+ /**
2187
+ * Carry the in-process provider-resolved marker across a managed snapshot.
2188
+ *
2189
+ * A managed shell deep-snapshots content, and symbol keys do not survive that.
2190
+ * Losing the marker would let the loop dispatch a tool call the provider already
2191
+ * executed, so it is re-attached by tool-call identity (`id` plus `name`), the
2192
+ * same way transient unicode-escape evidence is restored.
2193
+ */
2194
+ function restoreProviderResolvedMarkers(destination: AssistantMessage["content"], source: unknown): void {
2195
+ const sourceContent = managedProperty(source, "content");
2196
+ if (!Array.isArray(sourceContent)) return;
2197
+ const marked = sourceContent.filter(
2198
+ (block): block is object => typeof block === "object" && block !== null && isProviderResolvedToolCall(block),
2199
+ );
2200
+ if (marked.length === 0) return;
2201
+ for (const destinationBlock of destination) {
2202
+ if (destinationBlock.type !== "toolCall") continue;
2203
+ const matches = marked.filter(
2204
+ candidate =>
2205
+ managedProperty(candidate, "id") === destinationBlock.id &&
2206
+ managedProperty(candidate, "name") === destinationBlock.name,
2207
+ );
2208
+ // Ambiguity suppresses every candidate rather than dispatching a call the
2209
+ // provider may already have executed: this predicate is a safety gate.
2210
+ for (const match of matches) copyProviderResolvedToolCall(destinationBlock, match as object);
2211
+ }
2212
+ }
2213
+
2214
+ /**
2215
+ * Detach content the way `structuredClone` does, but keep the in-process
2216
+ * provider-resolved marker. The abort path clamps partial content and then
2217
+ * filters provider-resolved calls out of the synthesized aborted results, so a
2218
+ * plain structured clone would fabricate a failed tool result for a call the
2219
+ * provider already ran.
2220
+ */
2221
+ function cloneContentPreservingProviderResolved(content: AssistantMessage["content"]): AssistantMessage["content"] {
2222
+ const cloned = structuredClone(content) as AssistantMessage["content"];
2223
+ for (let index = 0; index < content.length; index += 1) {
2224
+ const source = content[index];
2225
+ const target = cloned[index];
2226
+ if (source?.type !== "toolCall" || target?.type !== "toolCall") continue;
2227
+ copyProviderResolvedToolCall(target, source);
2228
+ }
2229
+ return cloned;
2230
+ }
2231
+
2178
2232
  function managedUnicodeEscapeEvidence(value: unknown): UnicodeEscapeEvidence | undefined {
2179
2233
  if (!isManagedPlainRecord(value)) return undefined;
2180
2234
  const envelopeReads = Object.fromEntries(
@@ -2618,6 +2672,7 @@ class ManagedAttemptTransaction {
2618
2672
  #lastStagedShape: { stagedEventCount: number; stagedBytes: number; contentBlockCount: number } | undefined;
2619
2673
  #discarded = false;
2620
2674
  #committed = false;
2675
+ #rejected = false;
2621
2676
  #degradedFieldDiagnostics = new Set<string>();
2622
2677
 
2623
2678
  constructor(
@@ -2806,6 +2861,14 @@ class ManagedAttemptTransaction {
2806
2861
  return this.#committed;
2807
2862
  }
2808
2863
 
2864
+ reject(): void {
2865
+ this.#rejected = true;
2866
+ }
2867
+
2868
+ get rejected(): boolean {
2869
+ return this.#rejected;
2870
+ }
2871
+
2809
2872
  acceptedAssistantSnapshot(message: AssistantMessage): AssistantMessage {
2810
2873
  const sourceContent = Array.isArray(message.content) ? message.content : [];
2811
2874
  const sourceMetadata: Array<EscapedToolCallMetadata | undefined> = [];
@@ -3167,6 +3230,7 @@ class ManagedAttemptTransaction {
3167
3230
  managedProperty(snapshot, "content") as AssistantMessage["content"],
3168
3231
  value,
3169
3232
  );
3233
+ restoreProviderResolvedMarkers(managedProperty(snapshot, "content") as AssistantMessage["content"], value);
3170
3234
  }
3171
3235
  return snapshot;
3172
3236
  }
@@ -4042,7 +4106,7 @@ async function runLoopBody(
4042
4106
  message.stopReason !== "error" &&
4043
4107
  message.stopReason !== "aborted" &&
4044
4108
  escapedNonAsciiResampleAttempt < MAX_ESCAPED_NONASCII_RESAMPLES &&
4045
- !escapedToolTransaction?.committed &&
4109
+ (!escapedToolTransaction?.committed || escapedToolTransaction.rejected) &&
4046
4110
  hasEscapedNonAsciiToolCall(message)
4047
4111
  ) {
4048
4112
  escapedNonAsciiResampleAttempt++;
@@ -4199,7 +4263,7 @@ async function runLoopBody(
4199
4263
  // This maintains the tool_use/tool_result pairing that the API requires
4200
4264
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
4201
4265
  const toolCalls = message.content.filter(
4202
- (c): c is ToolCallContent => c.type === "toolCall" && !isCursorExecResolved(c),
4266
+ (c): c is ToolCallContent => c.type === "toolCall" && !isProviderResolvedToolCall(c),
4203
4267
  );
4204
4268
  const toolResults: ToolResultMessage[] = [];
4205
4269
  for (const toolCall of toolCalls) {
@@ -4232,7 +4296,7 @@ async function runLoopBody(
4232
4296
  // Check for tool calls
4233
4297
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
4234
4298
  const toolCalls = message.content.filter(
4235
- (c): c is ToolCallContent => c.type === "toolCall" && !isCursorExecResolved(c),
4299
+ (c): c is ToolCallContent => c.type === "toolCall" && !isProviderResolvedToolCall(c),
4236
4300
  );
4237
4301
  hasMoreToolCalls = toolCalls.length > 0;
4238
4302
 
@@ -4813,17 +4877,20 @@ async function streamAssistantResponse(
4813
4877
  const event = next.value;
4814
4878
 
4815
4879
  switch (event.type) {
4816
- case "start":
4880
+ case "start": {
4817
4881
  partialMessage = config.fallbackManaged
4818
4882
  ? managedAssistantShell(event.partial, config.model, managedDegradedFieldDiagnostics)
4819
4883
  : event.partial;
4820
4884
  context.messages.push(partialMessage);
4821
4885
  addedPartial = true;
4886
+ let acceptedStart = true;
4822
4887
  if (provisionalToolTransaction) {
4823
- config.onProvisionalAssistantMessageEvent?.(partialMessage, event);
4888
+ acceptedStart = config.onProvisionalAssistantMessageEvent?.(partialMessage, event) !== false;
4889
+ if (!acceptedStart) provisionalToolTransaction.reject();
4824
4890
  }
4825
- stream.push({ type: "message_start", message: { ...partialMessage }, scope });
4891
+ if (acceptedStart) stream.push({ type: "message_start", message: { ...partialMessage }, scope });
4826
4892
  break;
4893
+ }
4827
4894
 
4828
4895
  case "toolChoiceIncapability":
4829
4896
  config.onToolChoiceIncapability?.(event);
@@ -4856,26 +4923,28 @@ async function streamAssistantResponse(
4856
4923
  ? managedAssistantEventSnapshot(event, partialMessage, managedDegradedFieldDiagnostics)
4857
4924
  : event;
4858
4925
  context.messages[context.messages.length - 1] = partialMessage;
4926
+ let acceptedUpdate = true;
4859
4927
  if (provisionalToolTransaction) {
4860
- config.onProvisionalAssistantMessageEvent?.(partialMessage, partialEvent);
4861
- provisionalToolTransaction.stageAssistantMessageEvent(partialMessage, partialEvent);
4928
+ acceptedUpdate =
4929
+ config.onProvisionalAssistantMessageEvent?.(partialMessage, partialEvent) !== false;
4930
+ if (!acceptedUpdate) provisionalToolTransaction.reject();
4931
+ if (acceptedUpdate)
4932
+ provisionalToolTransaction.stageAssistantMessageEvent(partialMessage, partialEvent);
4862
4933
  } else {
4863
4934
  config.onAssistantMessageEvent?.(partialMessage, partialEvent);
4864
4935
  }
4865
- if (signal?.aborted) continue;
4936
+ if (!acceptedUpdate || signal?.aborted) continue;
4866
4937
  stream.push({
4867
4938
  type: "message_update",
4868
4939
  assistantMessageEvent: partialEvent,
4869
4940
  message: { ...partialMessage },
4870
4941
  scope,
4871
4942
  });
4872
- // Preserve ordinary text streaming. Once visible text is
4873
- // published, this response can no longer be silently resampled;
4874
- // a later escaped tool call therefore falls through to the
4875
- // existing terminal per-call rejection instead.
4876
- if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
4877
- provisionalToolTransaction?.commitCallbacksAndUpdates();
4878
- }
4943
+ // Publish unmanaged content as it arrives, including reasoning and
4944
+ // tool arguments. Publication does not accept or execute tools:
4945
+ // terminal validation and the consumer drain still precede dispatch.
4946
+ // A published escaped call is rejected rather than silently resampled.
4947
+ provisionalToolTransaction?.commitCallbacksAndUpdates();
4879
4948
  }
4880
4949
  break;
4881
4950
 
@@ -4935,7 +5004,7 @@ function emitAbortedAssistantMessage(
4935
5004
  const now = Date.now();
4936
5005
  const abortedMessage: AssistantMessage = {
4937
5006
  role: "assistant",
4938
- content: partialMessage ? structuredClone(partialMessage.content) : [],
5007
+ content: partialMessage ? cloneContentPreservingProviderResolved(partialMessage.content) : [],
4939
5008
  api: config.model.api,
4940
5009
  provider: config.model.provider,
4941
5010
  model: config.model.id,
@@ -5031,7 +5100,7 @@ async function executeToolCalls(
5031
5100
  } = config;
5032
5101
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
5033
5102
  const toolCalls = assistantMessage.content.filter(
5034
- (c): c is ToolCallContent => c.type === "toolCall" && !isCursorExecResolved(c),
5103
+ (c): c is ToolCallContent => c.type === "toolCall" && !isProviderResolvedToolCall(c),
5035
5104
  );
5036
5105
  const emittedToolResults: ToolResultMessage[] = [];
5037
5106
  const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
package/src/agent.ts CHANGED
@@ -542,7 +542,10 @@ export class Agent {
542
542
  #onResponse?: SimpleStreamOptions["onResponse"];
543
543
  #onSseEvent?: SimpleStreamOptions["onSseEvent"];
544
544
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
545
- #onProvisionalAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
545
+ #onProvisionalAssistantMessageEvent?: (
546
+ message: AssistantMessage,
547
+ event: AssistantMessageEvent,
548
+ ) => boolean | undefined;
546
549
  #onToolChoiceIncapability?: AgentLoopConfig["onToolChoiceIncapability"];
547
550
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
548
551
  #onBeforeYield?: () => Promise<void> | void;
@@ -928,7 +931,7 @@ export class Agent {
928
931
  }
929
932
 
930
933
  setProvisionalAssistantMessageEventInterceptor(
931
- fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined,
934
+ fn: ((message: AssistantMessage, event: AssistantMessageEvent) => boolean | undefined) | undefined,
932
935
  ): void {
933
936
  this.#onProvisionalAssistantMessageEvent = fn;
934
937
  }
@@ -1030,81 +1033,145 @@ export class Agent {
1030
1033
  // `this === undefined`, throwing "undefined is not an object (this.#optionsForCall)".
1031
1034
  const read = source.read?.bind(source);
1032
1035
  if (read) {
1033
- guarded.read = async args => {
1036
+ guarded.read = async (args, signal, markNonAbortable) => {
1034
1037
  this.#assertActiveRun(runId);
1035
- const result = await read(args);
1038
+ const result = await read(args, signal, markNonAbortable);
1036
1039
  this.#assertActiveRun(runId);
1037
1040
  return result;
1038
1041
  };
1039
1042
  }
1040
1043
  const ls = source.ls?.bind(source);
1041
1044
  if (ls) {
1042
- guarded.ls = async args => {
1045
+ guarded.ls = async (args, signal, markNonAbortable) => {
1043
1046
  this.#assertActiveRun(runId);
1044
- const result = await ls(args);
1047
+ const result = await ls(args, signal, markNonAbortable);
1045
1048
  this.#assertActiveRun(runId);
1046
1049
  return result;
1047
1050
  };
1048
1051
  }
1049
1052
  const grep = source.grep?.bind(source);
1050
1053
  if (grep) {
1051
- guarded.grep = async args => {
1054
+ guarded.grep = async (args, signal) => {
1052
1055
  this.#assertActiveRun(runId);
1053
- const result = await grep(args);
1056
+ const result = await grep(args, signal);
1054
1057
  this.#assertActiveRun(runId);
1055
1058
  return result;
1056
1059
  };
1057
1060
  }
1058
1061
  const write = source.write?.bind(source);
1059
1062
  if (write) {
1060
- guarded.write = async args => {
1063
+ guarded.write = async (args, signal, markNonAbortable) => {
1061
1064
  this.#assertActiveRun(runId);
1062
- const result = await write(args);
1065
+ const result = await write(args, signal, markNonAbortable);
1063
1066
  this.#assertActiveRun(runId);
1064
1067
  return result;
1065
1068
  };
1066
1069
  }
1067
1070
  const deleteHandler = source.delete?.bind(source);
1068
1071
  if (deleteHandler) {
1069
- guarded.delete = async args => {
1072
+ guarded.delete = async (args, signal, markNonAbortable) => {
1070
1073
  this.#assertActiveRun(runId);
1071
- const result = await deleteHandler(args);
1074
+ const result = await deleteHandler(args, signal, markNonAbortable);
1072
1075
  this.#assertActiveRun(runId);
1073
1076
  return result;
1074
1077
  };
1075
1078
  }
1076
1079
  const shell = source.shell?.bind(source);
1077
1080
  if (shell) {
1078
- guarded.shell = async args => {
1081
+ guarded.shell = async (args, signal, markNonAbortable) => {
1079
1082
  this.#assertActiveRun(runId);
1080
- const result = await shell(args);
1083
+ const result = await shell(args, signal, markNonAbortable);
1081
1084
  this.#assertActiveRun(runId);
1082
1085
  return result;
1083
1086
  };
1084
1087
  }
1085
1088
  const shellStream = source.shellStream?.bind(source);
1086
1089
  if (shellStream) {
1087
- guarded.shellStream = async (args, callbacks) => {
1090
+ guarded.shellStream = async (args, callbacks, signal, markNonAbortable) => {
1088
1091
  this.#assertActiveRun(runId);
1089
- const result = await shellStream(args, callbacks);
1092
+ const result = await shellStream(args, callbacks, signal, markNonAbortable);
1090
1093
  this.#assertActiveRun(runId);
1091
1094
  return result;
1092
1095
  };
1093
1096
  }
1094
1097
  const diagnostics = source.diagnostics?.bind(source);
1095
1098
  if (diagnostics) {
1096
- guarded.diagnostics = async args => {
1099
+ guarded.diagnostics = async (args, signal) => {
1097
1100
  this.#assertActiveRun(runId);
1098
- const result = await diagnostics(args);
1101
+ const result = await diagnostics(args, signal);
1099
1102
  this.#assertActiveRun(runId);
1100
1103
  return result;
1101
1104
  };
1102
1105
  }
1103
1106
  const mcp = source.mcp?.bind(source);
1104
1107
  if (mcp) {
1105
- guarded.mcp = async call => {
1108
+ guarded.mcp = async (call, signal, markNonAbortable) => {
1109
+ this.#assertActiveRun(runId);
1110
+ const result = await mcp(call, signal, markNonAbortable);
1111
+ this.#assertActiveRun(runId);
1112
+ return result;
1113
+ };
1114
+ }
1115
+ // Preserve Pi handlers end-to-end (probepark blocker): the production Agent guard previously dropped every pi* handler.
1116
+ const piRead = source.piRead?.bind(source);
1117
+ if (piRead) {
1118
+ guarded.piRead = async call => {
1119
+ this.#assertActiveRun(runId);
1120
+ const result = await piRead(call);
1121
+ this.#assertActiveRun(runId);
1122
+ return result;
1123
+ };
1124
+ }
1125
+ const piBash = source.piBash?.bind(source);
1126
+ if (piBash) {
1127
+ guarded.piBash = async call => {
1128
+ this.#assertActiveRun(runId);
1129
+ const result = await piBash(call);
1130
+ this.#assertActiveRun(runId);
1131
+ return result;
1132
+ };
1133
+ }
1134
+ const piEdit = source.piEdit?.bind(source);
1135
+ if (piEdit) {
1136
+ guarded.piEdit = async call => {
1137
+ this.#assertActiveRun(runId);
1138
+ const result = await piEdit(call);
1139
+ this.#assertActiveRun(runId);
1140
+ return result;
1141
+ };
1142
+ }
1143
+ const piWrite = source.piWrite?.bind(source);
1144
+ if (piWrite) {
1145
+ guarded.piWrite = async call => {
1146
+ this.#assertActiveRun(runId);
1147
+ const result = await piWrite(call);
1148
+ this.#assertActiveRun(runId);
1149
+ return result;
1150
+ };
1151
+ }
1152
+ const piGrep = source.piGrep?.bind(source);
1153
+ if (piGrep) {
1154
+ guarded.piGrep = async call => {
1106
1155
  this.#assertActiveRun(runId);
1107
- const result = await mcp(call);
1156
+ const result = await piGrep(call);
1157
+ this.#assertActiveRun(runId);
1158
+ return result;
1159
+ };
1160
+ }
1161
+ const piFind = source.piFind?.bind(source);
1162
+ if (piFind) {
1163
+ guarded.piFind = async call => {
1164
+ this.#assertActiveRun(runId);
1165
+ const result = await piFind(call);
1166
+ this.#assertActiveRun(runId);
1167
+ return result;
1168
+ };
1169
+ }
1170
+ const piLs = source.piLs?.bind(source);
1171
+ if (piLs) {
1172
+ guarded.piLs = async call => {
1173
+ this.#assertActiveRun(runId);
1174
+ const result = await piLs(call);
1108
1175
  this.#assertActiveRun(runId);
1109
1176
  return result;
1110
1177
  };
@@ -1984,9 +2051,9 @@ export class Agent {
1984
2051
  this.#onAssistantMessageEvent?.(message, event);
1985
2052
  },
1986
2053
  onProvisionalAssistantMessageEvent: (message, event) => {
1987
- if (this.#activeRunId !== runId) return;
2054
+ if (this.#activeRunId !== runId) return false;
1988
2055
  this.#state.streamMessage = message;
1989
- this.#onProvisionalAssistantMessageEvent?.(message, event);
2056
+ return this.#onProvisionalAssistantMessageEvent?.(message, event);
1990
2057
  },
1991
2058
  hasProvisionalAssistantMessageEventConsumer: this.#onProvisionalAssistantMessageEvent !== undefined,
1992
2059
  onToolChoiceIncapability: this.#onToolChoiceIncapability
@@ -93,6 +93,12 @@ export interface GenerateBranchSummaryOptions {
93
93
  sessionId?: string;
94
94
  /** Opaque provider conversation identity; never derived from branch-summary content. */
95
95
  providerSessionId?: string;
96
+ /**
97
+ * Marks this as GJC maintenance work rather than an interactive user turn.
98
+ * Agent-level providers (e.g. `devin-acp`) refuse maintenance calls they
99
+ * cannot serve instead of forwarding them to a billed upstream agent.
100
+ */
101
+ maintenanceCall?: boolean;
96
102
  /** Shared provider state map so the branch summary call reuses session-scoped transport/session caches. */
97
103
  providerSessionState?: Map<string, ProviderSessionState>;
98
104
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
@@ -337,6 +343,7 @@ export async function generateBranchSummary(
337
343
  sessionId,
338
344
  providerSessionId,
339
345
  providerSessionState,
346
+ maintenanceCall: options.maintenanceCall,
340
347
  preferWebsockets,
341
348
  },
342
349
  { telemetry: options.telemetry, oneshotKind: "branch_summary" },
@@ -868,6 +868,12 @@ export interface SummaryOptions {
868
868
  remoteEndpoint?: string;
869
869
  remoteInstructions?: string;
870
870
  initiatorOverride?: MessageAttribution;
871
+ /**
872
+ * Marks this as GJC maintenance work rather than an interactive user turn.
873
+ * Agent-level providers (e.g. `devin-acp`) refuse maintenance calls they
874
+ * cannot serve instead of forwarding them to a billed upstream agent.
875
+ */
876
+ maintenanceCall?: boolean;
871
877
  metadata?: Record<string, unknown>;
872
878
  convertToLlm?: ConvertToLlm;
873
879
  /**
@@ -1026,6 +1032,7 @@ export async function generateSummary(
1026
1032
  apiKey,
1027
1033
  reasoning: maintenanceReasoning(model),
1028
1034
  initiatorOverride: options?.initiatorOverride,
1035
+ maintenanceCall: options?.maintenanceCall,
1029
1036
  metadata: options?.metadata,
1030
1037
  sessionId: options?.sessionId,
1031
1038
  providerSessionId: options?.providerSessionId,
@@ -1065,6 +1072,12 @@ export interface HandoffOptions {
1065
1072
  promptExtension?: string;
1066
1073
  convertToLlm?: ConvertToLlm;
1067
1074
  initiatorOverride?: MessageAttribution;
1075
+ /**
1076
+ * Marks this as GJC maintenance work rather than an interactive user turn.
1077
+ * Agent-level providers (e.g. `devin-acp`) refuse maintenance calls they
1078
+ * cannot serve instead of forwarding them to a billed upstream agent.
1079
+ */
1080
+ maintenanceCall?: boolean;
1068
1081
  metadata?: Record<string, unknown>;
1069
1082
  /**
1070
1083
  * Optional telemetry handle. When provided, the handoff LLM call is
@@ -1124,6 +1137,7 @@ export async function generateHandoff(
1124
1137
  reasoning: maintenanceReasoning(model),
1125
1138
  toolChoice: "none",
1126
1139
  initiatorOverride: options.initiatorOverride,
1140
+ maintenanceCall: options.maintenanceCall,
1127
1141
  metadata: options.metadata,
1128
1142
  sessionId: options.sessionId,
1129
1143
  providerSessionId: options.providerSessionId,
@@ -1397,6 +1411,7 @@ export async function compact(
1397
1411
  remoteEndpoint: settings.remoteEnabled === false ? undefined : settings.remoteEndpoint,
1398
1412
  remoteInstructions: options?.remoteInstructions,
1399
1413
  initiatorOverride: options?.initiatorOverride,
1414
+ maintenanceCall: options?.maintenanceCall,
1400
1415
  metadata: options?.metadata,
1401
1416
  convertToLlm: options?.convertToLlm,
1402
1417
  telemetry: options?.telemetry,
@@ -1574,6 +1589,7 @@ async function generateTurnPrefixSummary(
1574
1589
  apiKey,
1575
1590
  reasoning: maintenanceReasoning(model),
1576
1591
  initiatorOverride: options?.initiatorOverride,
1592
+ maintenanceCall: options?.maintenanceCall,
1577
1593
  metadata: options?.metadata,
1578
1594
  sessionId: options?.sessionId,
1579
1595
  providerSessionId: options?.providerSessionId,
package/src/types.ts CHANGED
@@ -485,8 +485,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
485
485
  * Callers may abort synchronously to stop consuming buffered provider events.
486
486
  */
487
487
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
488
- /** Observe unmanaged provisional assistant deltas before public publication. */
489
- onProvisionalAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
488
+ /** Observe unmanaged provisional assistant deltas before public publication. Return false to reject the turn. */
489
+ onProvisionalAssistantMessageEvent?: (
490
+ message: AssistantMessage,
491
+ event: AssistantMessageEvent,
492
+ ) => boolean | undefined;
490
493
  /** True when the host consumes provisional assistant events for live safety checks. */
491
494
  hasProvisionalAssistantMessageEventConsumer?: boolean;
492
495