@caupulican/pi-agent-core 0.92.0 → 0.93.0

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.
@@ -5,10 +5,11 @@
5
5
  import { EventStream } from "@caupulican/pi-ai/event-stream";
6
6
  import { ToolFailureRecoveryGate } from "./tool-failure-recovery-gate.ts";
7
7
  import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentToolCall, StreamFn, ToolCallRepairInfo } from "./types.ts";
8
- export { narrowRequestMaxTokens, resolveRequestPreflightMaxTokens } from "./provider-request-planner.ts";
8
+ export { composeRequestSystemPrompt, narrowRequestMaxTokens, resolveRequestPreflightMaxTokens, } from "./provider-request-planner.ts";
9
9
  export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
10
10
  /** Bounded no-progress state retained across host-owned continuations of one logical prompt. */
11
11
  export interface AgentLoopContinuationState {
12
+ providerTurns: number;
12
13
  stallWindow: string[];
13
14
  toolFailureRecoveryGate: ToolFailureRecoveryGate;
14
15
  }
@@ -1 +1 @@
1
- {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAmC7D,OAAO,EAGN,uBAAuB,EAGvB,MAAM,iCAAiC,CAAC;AAGzC,OAAO,KAAK,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,YAAY,EAEZ,aAAa,EAEb,QAAQ,EACR,kBAAkB,EAClB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,sBAAsB,EAAE,gCAAgC,EAAE,MAAM,+BAA+B,CAAC;AAEzG,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AAEzE,gGAAgG;AAChG,MAAM,WAAW,0BAA0B;IAC1C,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,uBAAuB,EAAE,uBAAuB,CAAC;CACjD;AAED,wBAAgB,gCAAgC,IAAI,0BAA0B,CAE7E;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAEzC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAIzC;AAuBD,wBAAsB,YAAY,CACjC,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,EACnB,iBAAiB,GAAE,0BAA+D,GAChF,OAAO,CAAC,YAAY,EAAE,CAAC,CAgBzB;AAED,wBAAsB,oBAAoB,CACzC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,EACnB,iBAAiB,GAAE,0BAA+D,GAChF,OAAO,CAAC,YAAY,EAAE,CAAC,CAWzB;AAgWD;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAExC;AAqjCD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,kBAAkB,GAAG,SAAS,CAO7F"}
1
+ {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../src/agent-loop.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,gCAAgC,CAAC;AAiC7D,OAAO,EAGN,uBAAuB,EAGvB,MAAM,iCAAiC,CAAC;AAEzC,OAAO,KAAK,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,YAAY,EAEZ,aAAa,EAEb,QAAQ,EACR,kBAAkB,EAClB,MAAM,YAAY,CAAC;AAIpB,OAAO,EACN,0BAA0B,EAC1B,sBAAsB,EACtB,gCAAgC,GAChC,MAAM,+BAA+B,CAAC;AAEvC,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;AAEzE,gGAAgG;AAChG,MAAM,WAAW,0BAA0B;IAC1C,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,uBAAuB,EAAE,uBAAuB,CAAC;CACjD;AAED,wBAAgB,gCAAgC,IAAI,0BAA0B,CAE7E;AAED;;;GAGG;AACH,wBAAgB,SAAS,CACxB,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAEzC;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAChC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,GACjB,WAAW,CAAC,UAAU,EAAE,YAAY,EAAE,CAAC,CAIzC;AAuBD,wBAAsB,YAAY,CACjC,OAAO,EAAE,YAAY,EAAE,EACvB,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,EACnB,iBAAiB,GAAE,0BAA+D,GAChF,OAAO,CAAC,YAAY,EAAE,CAAC,CAgBzB;AAED,wBAAsB,oBAAoB,CACzC,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,IAAI,EAAE,cAAc,EACpB,MAAM,CAAC,EAAE,WAAW,EACpB,QAAQ,CAAC,EAAE,QAAQ,EACnB,iBAAiB,GAAE,0BAA+D,GAChF,OAAO,CAAC,YAAY,EAAE,CAAC,CAWzB;AAoVD;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,YAAY,EACrB,MAAM,EAAE,eAAe,EACvB,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,QAAQ,CAAC,EAAE,QAAQ,GACjB,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAExC;AAqjCD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,aAAa,GAAG,kBAAkB,GAAG,SAAS,CAO7F"}
@@ -5,17 +5,16 @@
5
5
  import { EventStream } from "@caupulican/pi-ai/event-stream";
6
6
  import { formatToolRepairStandingRule, REPEATED_SUCCESSFUL_TOOL_CALL_FAILURE, } from "@caupulican/pi-ai/tool-repair-registry";
7
7
  import { ToolArgumentValidationError, validateToolArguments, } from "@caupulican/pi-ai/validation";
8
- import { assistantMessageText, collapseDegenerateAssistantMessage, isCollapsedDegenerateAssistantMessage, isDegenerateRepeatedText, shouldAbortDegenerateStream, } from "./degenerate-assistant-text.js";
8
+ import { assistantMessageText, collapseDegenerateAssistantMessage, shouldAbortDegenerateStream, } from "./degenerate-assistant-text.js";
9
9
  import { startPlannedAgentProviderRequest } from "./provider-request-planner.js";
10
10
  import { assessToolFailure, clearToolFailure, createRepeatedToolFailureResult, createToolFailureMemoryTracker, createToolFailureOperationExhaustedResult, createToolFailureRecoveryExhaustedResult, createToolFailureResult, getUnresolvedToolFailure, normalizeToolSignature, rememberToolFailure, toolFailureCorrection, } from "./tool-failure-memory.js";
11
11
  import { TOOL_FAILURE_RECOVERY_ACCOUNTING_WAVE_SIZE, ToolFailureRecoveryGate, } from "./tool-failure-recovery-gate.js";
12
- import { appendMandatoryToolFailureDeliveryPrompt } from "./tool-failure-recovery-protocol.js";
13
12
  import { rejectNativeToolProtocolResidue } from "./tool-protocol-residue.js";
14
- import { DEFAULT_MAX_STALL_TURNS } from "./types.js";
13
+ import { DEFAULT_MAX_PROVIDER_TURNS, DEFAULT_MAX_STALL_TURNS } from "./types.js";
15
14
  import { createEmptyUsage } from "./usage.js";
16
- export { narrowRequestMaxTokens, resolveRequestPreflightMaxTokens } from "./provider-request-planner.js";
15
+ export { composeRequestSystemPrompt, narrowRequestMaxTokens, resolveRequestPreflightMaxTokens, } from "./provider-request-planner.js";
17
16
  export function createAgentLoopContinuationState() {
18
- return { stallWindow: [], toolFailureRecoveryGate: new ToolFailureRecoveryGate() };
17
+ return { providerTurns: 0, stallWindow: [], toolFailureRecoveryGate: new ToolFailureRecoveryGate() };
19
18
  }
20
19
  /**
21
20
  * Start an agent loop with a new prompt message.
@@ -96,30 +95,17 @@ function createLoopFailureMessage(error, config, aborted) {
96
95
  timestamp: Date.now(),
97
96
  };
98
97
  }
99
- function mandatoryDeliveryReportsHalt(message, halt) {
100
- const raw = assistantMessageText(message);
101
- if (!raw.trim())
102
- return false;
103
- if (isCollapsedDegenerateAssistantMessage(message) || isDegenerateRepeatedText(raw))
104
- return false;
105
- const text = raw.toLowerCase();
106
- if (text.includes("recovery"))
107
- return true;
108
- if (text.includes(halt.record.failureCode.toLowerCase()))
109
- return true;
110
- const diagnostic = halt.record.diagnostic ?? halt.diagnostic;
111
- return diagnostic !== undefined && text.includes(diagnostic.toLowerCase().slice(0, 40));
112
- }
113
98
  function createMandatoryRecoveryDeliveryFallback(halt, config) {
114
99
  const diagnostic = halt.record.diagnostic ?? halt.diagnostic;
100
+ return createLocalDiagnosticMessage(config, `Tool recovery stopped for ${halt.record.tool}: ${diagnostic} Required recovery: ${halt.record.correction}`);
101
+ }
102
+ function createProviderTurnLimitMessage(config, providerTurns) {
103
+ return createLocalDiagnosticMessage(config, `Provider turn limit (${providerTurns}) reached. The harness stopped before another paid model request; continue explicitly if more work is needed.`);
104
+ }
105
+ function createLocalDiagnosticMessage(config, text) {
115
106
  return {
116
107
  role: "assistant",
117
- content: [
118
- {
119
- type: "text",
120
- text: `Tool recovery stopped for ${halt.record.tool}: ${diagnostic} Required recovery: ${halt.record.correction}`,
121
- },
122
- ],
108
+ content: [{ type: "text", text }],
123
109
  api: config.model.api,
124
110
  provider: config.model.provider,
125
111
  model: config.model.id,
@@ -128,6 +114,14 @@ function createMandatoryRecoveryDeliveryFallback(halt, config) {
128
114
  timestamp: Date.now(),
129
115
  };
130
116
  }
117
+ async function emitTerminalLocalMessage(message, newMessages, emit, startTurn) {
118
+ if (startTurn)
119
+ await emit({ type: "turn_start" });
120
+ await emit({ type: "message_start", message });
121
+ await emit({ type: "message_end", message });
122
+ await emit({ type: "turn_end", message, toolResults: [] });
123
+ await emit({ type: "agent_end", messages: newMessages });
124
+ }
131
125
  function createAgentStream() {
132
126
  return new EventStream((event) => event.type === "agent_end", (event) => (event.type === "agent_end" ? event.messages : []));
133
127
  }
@@ -200,13 +194,13 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
200
194
  // window slides past it. Counts only turns that issued tool calls, so varied/long work never trips
201
195
  // it. `0` disables.
202
196
  const stallLimit = config.maxStallTurns ?? DEFAULT_MAX_STALL_TURNS;
197
+ const providerTurnLimit = config.maxProviderTurns ?? DEFAULT_MAX_PROVIDER_TURNS;
203
198
  const stallWindow = continuationState.stallWindow;
204
199
  const validationFailureTracker = { repeats: 0 };
205
200
  const repairTeachTracker = new Map();
206
201
  let toolFailureMemory = createToolFailureMemoryTracker(currentContext.messages);
207
202
  const toolFailureRecoveryGate = continuationState.toolFailureRecoveryGate;
208
203
  toolFailureRecoveryGate.restoreFromMessages(currentContext.messages);
209
- let mandatoryRecoveryDeliveryPending = false;
210
204
  let lastSuccessfulTextProtocolBatch;
211
205
  // Check for steering messages at start (user may have typed while waiting)
212
206
  let pendingMessages = (await config.getSteeringMessages?.()) || [];
@@ -232,26 +226,20 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
232
226
  }
233
227
  pendingMessages = [];
234
228
  }
235
- // Recovery exhaustion receives exactly one provider turn without tools so the mandatory
236
- // diagnostic can reach the user instead of terminating on an undelivered tool result.
237
- const recoveryDeliveryTurn = mandatoryRecoveryDeliveryPending;
238
- const recoveryDeliveryHalt = recoveryDeliveryTurn ? toolFailureRecoveryGate.getHalt() : undefined;
239
- if (recoveryDeliveryTurn && !recoveryDeliveryHalt) {
240
- throw new Error("Mandatory recovery delivery started without a recovery halt");
229
+ if (providerTurnLimit > 0 && continuationState.providerTurns >= providerTurnLimit) {
230
+ const fallback = createProviderTurnLimitMessage(config, continuationState.providerTurns);
231
+ currentContext.messages.push(fallback);
232
+ newMessages.push(fallback);
233
+ config.onRunawayStop?.({
234
+ reason: "provider_turn_limit",
235
+ signature: "provider_turn_limit",
236
+ repeats: continuationState.providerTurns,
237
+ });
238
+ await emitTerminalLocalMessage(fallback, newMessages, emit, false);
239
+ return;
241
240
  }
242
- const responseContext = recoveryDeliveryHalt
243
- ? {
244
- ...currentContext,
245
- systemPrompt: appendMandatoryToolFailureDeliveryPrompt(currentContext.systemPrompt, {
246
- tool: recoveryDeliveryHalt.record.tool,
247
- failureCode: recoveryDeliveryHalt.record.failureCode,
248
- diagnostic: recoveryDeliveryHalt.record.diagnostic ?? recoveryDeliveryHalt.diagnostic,
249
- requiredAction: recoveryDeliveryHalt.record.correction,
250
- }),
251
- tools: [],
252
- }
253
- : currentContext;
254
- const message = await streamAssistantResponse(responseContext, config, signal, emit, streamFn);
241
+ continuationState.providerTurns++;
242
+ const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
255
243
  newMessages.push(message);
256
244
  if (message.stopReason === "error" || message.stopReason === "aborted") {
257
245
  await emit({ type: "turn_end", message, toolResults: [] });
@@ -261,30 +249,7 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
261
249
  // Check for tool calls
262
250
  const toolCalls = message.content.filter((c) => c.type === "toolCall");
263
251
  const toolResults = [];
264
- if (recoveryDeliveryTurn) {
265
- if (!recoveryDeliveryHalt) {
266
- throw new Error("Mandatory recovery delivery continued without a recovery halt");
267
- }
268
- if (toolCalls.length > 0) {
269
- toolResults.push(...(await rejectRecoveryDeliveryToolCalls(toolCalls, recoveryDeliveryHalt, emit)));
270
- for (const result of toolResults) {
271
- currentContext.messages.push(result);
272
- newMessages.push(result);
273
- }
274
- }
275
- await emit({ type: "turn_end", message, toolResults });
276
- if (toolCalls.length > 0 || !mandatoryDeliveryReportsHalt(message, recoveryDeliveryHalt)) {
277
- const fallback = createMandatoryRecoveryDeliveryFallback(recoveryDeliveryHalt, config);
278
- currentContext.messages.push(fallback);
279
- newMessages.push(fallback);
280
- await emit({ type: "turn_start" });
281
- await emit({ type: "message_start", message: fallback });
282
- await emit({ type: "message_end", message: fallback });
283
- await emit({ type: "turn_end", message: fallback, toolResults: [] });
284
- }
285
- await emit({ type: "agent_end", messages: newMessages });
286
- return;
287
- }
252
+ let recoveryHalt;
288
253
  hasMoreToolCalls = false;
289
254
  if (toolCalls.length > 0) {
290
255
  const textProtocolBatch = toolCalls.every((toolCall) => toolCall.source === "text-protocol");
@@ -296,8 +261,9 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
296
261
  const executedToolBatch = await executeToolCalls(currentContext, message, config, validationFailureTracker, repairTeachTracker, toolFailureMemory, toolFailureRecoveryGate, previousSuccessfulTextProtocolResults, signal, emit);
297
262
  toolResults.push(...executedToolBatch.messages);
298
263
  if (toolFailureRecoveryGate.isHalted()) {
299
- mandatoryRecoveryDeliveryPending = true;
300
- hasMoreToolCalls = true;
264
+ recoveryHalt = toolFailureRecoveryGate.getHalt();
265
+ if (!recoveryHalt)
266
+ throw new Error("Tool recovery halted without a failure record");
301
267
  }
302
268
  else {
303
269
  hasMoreToolCalls = !executedToolBatch.terminate;
@@ -323,15 +289,22 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
323
289
  lastSuccessfulTextProtocolBatch = undefined;
324
290
  }
325
291
  await emit({ type: "turn_end", message, toolResults });
292
+ if (recoveryHalt) {
293
+ const fallback = createMandatoryRecoveryDeliveryFallback(recoveryHalt, config);
294
+ currentContext.messages.push(fallback);
295
+ newMessages.push(fallback);
296
+ await emitTerminalLocalMessage(fallback, newMessages, emit, true);
297
+ return;
298
+ }
326
299
  // Runaway-loop backstop (cost guard): detect a model stuck repeating one action.
327
- if (!mandatoryRecoveryDeliveryPending && stallLimit > 0 && toolCalls.length > 0) {
300
+ if (stallLimit > 0 && toolCalls.length > 0) {
328
301
  const signature = normalizeToolSignature(toolCalls.map((c) => [c.name, c.arguments ?? null]));
329
302
  stallWindow.push(signature);
330
303
  if (stallWindow.length > stallLimit * STALL_WINDOW_PERIODS)
331
304
  stallWindow.shift();
332
305
  const repeats = stallWindow.reduce((n, s) => (s === signature ? n + 1 : n), 0);
333
306
  if (repeats >= stallLimit) {
334
- config.onRunawayStop?.({ signature, repeats });
307
+ config.onRunawayStop?.({ reason: "repeated_tool_call", signature, repeats });
335
308
  await emit({ type: "agent_end", messages: newMessages });
336
309
  return;
337
310
  }
@@ -354,13 +327,12 @@ async function runLoop(initialContext, newMessages, initialConfig, signal, emit,
354
327
  reasoning: nextTurnSnapshot.thinkingLevel ?? config.reasoning,
355
328
  };
356
329
  }
357
- if (!mandatoryRecoveryDeliveryPending &&
358
- (await config.shouldStopAfterTurn?.({
359
- message,
360
- toolResults,
361
- context: currentContext,
362
- newMessages,
363
- }))) {
330
+ if (await config.shouldStopAfterTurn?.({
331
+ message,
332
+ toolResults,
333
+ context: currentContext,
334
+ newMessages,
335
+ })) {
364
336
  await emit({ type: "agent_end", messages: newMessages });
365
337
  return;
366
338
  }
@@ -1166,20 +1138,4 @@ async function emitToolResultMessage(toolResultMessage, emit) {
1166
1138
  await emit({ type: "message_start", message: toolResultMessage });
1167
1139
  await emit({ type: "message_end", message: toolResultMessage });
1168
1140
  }
1169
- async function rejectRecoveryDeliveryToolCalls(toolCalls, halt, emit) {
1170
- const messages = [];
1171
- for (const toolCall of toolCalls) {
1172
- await emitToolExecutionStart(toolCall, emit);
1173
- const finalized = {
1174
- toolCall,
1175
- result: createToolFailureRecoveryExhaustedResult(halt.record, "Tool execution is disabled during the mandatory recovery delivery turn."),
1176
- isError: true,
1177
- };
1178
- await emitToolExecutionEnd(finalized, emit);
1179
- const message = createToolResultMessage(finalized);
1180
- await emitToolResultMessage(message, emit);
1181
- messages.push(message);
1182
- }
1183
- return messages;
1184
- }
1185
1141
  //# sourceMappingURL=agent-loop.js.map