loopengine 0.0.21 → 0.1.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.
Files changed (38) hide show
  1. package/PROTOCOL.md +3 -3
  2. package/README.md +45 -10
  3. package/dist/core/agent-config.d.ts +282 -3
  4. package/dist/core/budget.d.ts +42 -0
  5. package/dist/core/budget.js +39 -0
  6. package/dist/core/compaction.d.ts +43 -0
  7. package/dist/core/compaction.js +70 -0
  8. package/dist/core/http-notifier.d.ts +10 -0
  9. package/dist/core/http-notifier.js +116 -0
  10. package/dist/core/http-notify-triggers/database.d.ts +19 -0
  11. package/dist/core/http-notify-triggers/database.js +53 -0
  12. package/dist/core/http-notify-triggers/email.d.ts +44 -0
  13. package/dist/core/http-notify-triggers/email.js +126 -0
  14. package/dist/core/http-notify-triggers/lark.d.ts +28 -0
  15. package/dist/core/http-notify-triggers/lark.js +168 -0
  16. package/dist/core/http-notify-triggers/redis.d.ts +33 -0
  17. package/dist/core/http-notify-triggers/redis.js +54 -0
  18. package/dist/core/http-notify-triggers/slack.d.ts +21 -0
  19. package/dist/core/http-notify-triggers/slack.js +91 -0
  20. package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
  21. package/dist/core/http-notify-triggers/webhook.js +94 -0
  22. package/dist/core/loop-events.d.ts +30 -6
  23. package/dist/core/model-calls/openai-model-call.js +32 -16
  24. package/dist/core/recovery.d.ts +49 -0
  25. package/dist/core/recovery.js +124 -0
  26. package/dist/core/run-agent.d.ts +141 -13
  27. package/dist/core/run-agent.js +296 -54
  28. package/dist/core/session-store.d.ts +1 -0
  29. package/dist/core/session-store.js +6 -4
  30. package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
  31. package/dist/core/system-tools/ask_user.d.ts +97 -31
  32. package/dist/core/system-tools/ask_user.js +157 -58
  33. package/dist/core/system-tools/index.d.ts +1 -1
  34. package/dist/core/system-tools/index.js +1 -1
  35. package/dist/package.json +6 -0
  36. package/package.json +14 -4
  37. package/protocol/loop-event.schema.json +2 -2
  38. /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
@@ -1,5 +1,5 @@
1
1
  import { RuleSet, type Approver } from 'actauth';
2
- import type { AgentConfig, ToolDefinition, ToolSchema } from '#core/agent-config.js';
2
+ import type { AgentConfig, ApproverChannel, QuestionHandler, ToolDefinition, ToolSchema } from '#core/agent-config.js';
3
3
  import { type PendingQuestion } from './system-tools/index.js';
4
4
  import type { LoopEvent } from './loop-events.js';
5
5
  export { systemTools } from './system-tools/index.js';
@@ -8,7 +8,18 @@ export declare const systemSkillsDir: string;
8
8
  export declare function dedupeToolsByName(list: ToolDefinition[]): ToolDefinition[];
9
9
  export interface ModelContentBlock {
10
10
  type: string;
11
- /** text blocks. */
11
+ /** text blocks, and thinking blocks (a model's own chain-of-thought,
12
+ * e.g. DeepSeek's `reasoning_content` sibling field, or — if a
13
+ * model-calls/* adapter ever opts into it — Anthropic's own `thinking`
14
+ * content block). Reuses this same field rather than adding a
15
+ * thinking-only one, since both are just "a string of the model's own
16
+ * words." Carried opaquely: loopengine never reads or reasons about
17
+ * this text itself, only round-trips it back verbatim on the next
18
+ * request that includes this message — some reasoning-mode providers
19
+ * (DeepSeek's thinking mode, confirmed live; Anthropic's extended
20
+ * thinking, documented the same way) reject the request outright if a
21
+ * prior turn's reasoning gets silently dropped on the way back in,
22
+ * once the conversation has tool_calls in it. */
12
23
  text?: string;
13
24
  /** tool_use blocks: this call's own id (referenced by a later tool_result). */
14
25
  id?: string;
@@ -73,15 +84,32 @@ export interface RunAgentOptions {
73
84
  * pending question can't be session-scoped — still fine, it's still
74
85
  * agent-scoped either way. */
75
86
  sessionId?: string;
76
- /** Default Approver for this call's 'ask' decisions, when
77
- * AgentConfig.approver isn't set — the seam an adapter uses to pick
78
- * whichever approver actually fits its own channel (ConsoleApprover for
79
- * a real terminal, a web-based one for an HTTP-served conversation,
80
- * ...) without every agent needing to hardcode one itself. An explicit
81
- * AgentConfig.approver still wins outright over this when the agent
82
- * author set one on purpose — same as it always has (see Gate
83
- * construction below) — this only fills in when that's absent. Default,
84
- * if neither is given: actauth's own ConsoleApprover, same as always. */
87
+ /** Which channel this call is on — checked below against `'http'`,
88
+ * the only value `AgentConfig.httpNotifier` ever matches (see that
89
+ * field's own doc comment). Omitted (a standalone script, a bespoke
90
+ * dispatcher with no fixed channel identity) means `httpNotifier` is
91
+ * never consulted at all — `approver` below is the only thing that
92
+ * applies. */
93
+ channel?: ApproverChannel;
94
+ /** This call's own default Approver for 'ask' decisions, when
95
+ * `AgentConfig.httpNotifier` doesn't supply one (only possible on the
96
+ * `http` channel, and only when it lists `'approval'`) — the seam an
97
+ * adapter uses to pick whichever approver actually fits its own channel
98
+ * (ConsoleApprover for a real terminal, a fresh WebchatApprover per
99
+ * streamed turn, a WebhookApprover for a background dispatcher,
100
+ * ...) without every agent needing to hardcode one itself. `http`'s own
101
+ * `httpNotifier`, if it covers `'approval'`, still wins outright over
102
+ * this — the agent author's own explicit choice *for that channel*
103
+ * beats whatever the adapter would otherwise default to (see Gate
104
+ * construction below) — `cli`/`http_stream` have no such override at
105
+ * all, so this is the only thing that ever applies there. Default, if
106
+ * neither is given: actauth's own ConsoleApprover, same as always.
107
+ *
108
+ * Can be a DurableApprover (WebhookApprover, ...) instead of a live
109
+ * one — see HUMAN_IN_THE_LOOP.md. A background/cron dispatcher calling
110
+ * runAgent() directly is exactly this same kind of caller, and should
111
+ * pass its own durable default here the same way adapters/http.ts's
112
+ * stream route passes a fresh WebchatApprover. */
85
113
  approver?: Approver;
86
114
  /** Fired the instant the system ask_user tool registers a new pending
87
115
  * question — this is the seam that decides between a real, answerable
@@ -100,6 +128,30 @@ export interface RunAgentOptions {
100
128
  * that genuinely has an answering mechanism (adapters/http.ts, for both
101
129
  * its routes) should pass this. */
102
130
  onQuestionPending?: (question: PendingQuestion) => void;
131
+ /** This call's own default QuestionHandler — question-side sibling of
132
+ * `approver` above, same precedence (`AgentConfig.httpNotifier`, when it
133
+ * covers `'question'` on the `http` channel, still wins outright over
134
+ * this) and the same `LiveQuestionHandler | DurableQuestionHandler`
135
+ * union `approver` itself is, duck-typed the same way (see
136
+ * `isDurableQuestionHandler`). Default, if neither this nor
137
+ * `httpNotifier` supplies one: `new CliQuestionHandler()` — same role
138
+ * `new ConsoleApprover()` plays for `approver`.
139
+ *
140
+ * One real, current asymmetry versus `approver`: only the *durable*
141
+ * half of this union is actually wired into the loop below — a
142
+ * `system_ask_user` call only ever consults this field to decide
143
+ * live-vs-durable; the live case still always falls through to
144
+ * `onQuestionPending`/the tool's own independent live resolution
145
+ * (unchanged from before this field existed), never actually calling
146
+ * a live `QuestionHandler` resolved here. See HUMAN_IN_THE_LOOP.md's
147
+ * "Durable questions" section for why: fully wiring the live half too
148
+ * would mean moving system_ask_user's answer-collection inline into
149
+ * this loop (changing today's batching semantics — right now a
150
+ * pending question is deferred past the whole tool_use batch scan,
151
+ * unlike a live approval) and a breaking change to createAskUserTool's
152
+ * own signature — deliberately deferred rather than done half-carefully
153
+ * here. */
154
+ questionHandler?: QuestionHandler;
103
155
  }
104
156
  export interface RunAgentResult {
105
157
  /** Final assistant text for this turn. */
@@ -132,8 +184,62 @@ export interface RunAgentResult {
132
184
  * model requested in the same turn, even one already approved, never
133
185
  * runs either (see the loop's own body); it gets a "skipped", not a
134
186
  * "denied", tool_result, since it was never evaluated against its own
135
- * rule. */
136
- stopReason?: 'max_turns' | 'denied';
187
+ * rule.
188
+ *
189
+ * 'pending_approval' / 'pending_question': at least one requested tool
190
+ * call is durably pending a DurableApprover decision, or at least one
191
+ * `system_ask_user` call is durably pending a DurableQuestionHandler
192
+ * answer (see HUMAN_IN_THE_LOOP.md) — same "the whole batch stops here"
193
+ * shape as 'denied', except this isn't a rejection: any already-run
194
+ * sibling call in the same batch still ran (see the loop's own
195
+ * bucket-then-execute), captured in `pending.resultsSoFar`, and the
196
+ * turn resumes later via resumeAgent() once every pendingId in
197
+ * `pending.outstanding` is resolved — not by sending another message
198
+ * the way 'denied' recovers. A batch can mix both kinds (a gated tool
199
+ * call and an `system_ask_user` call in the same model response) — both
200
+ * land in the same `pending.outstanding`, since the one dangling
201
+ * assistant tool_use message can only ever get one completing
202
+ * tool_result message; `stopReason` reports 'pending_approval' for that
203
+ * mixed case too (only a batch with *no* approval items at all reports
204
+ * 'pending_question'). `newMessages` ends at the dangling assistant
205
+ * tool_use message, same crash-recovery shape session-store.ts's own
206
+ * hasUnresolvedToolCall already detects — entered on purpose here,
207
+ * not by accident. runAgent() does no I/O (see this module's own
208
+ * header comment): turning `pending` into a durable TurnCheckpoint
209
+ * (core/durable-approvals.ts) is the caller's job. */
210
+ stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question';
211
+ /** Set only when stopReason is 'pending_approval' or 'pending_question'. */
212
+ pending?: {
213
+ /** tool_result blocks already computed this batch — safely
214
+ * auto-allowed calls that ran immediately despite a gated/asked
215
+ * sibling still being outstanding. Not yet pushed into `newMessages`/
216
+ * `history`: incomplete until every outstanding item below is
217
+ * resolved too, and a real model API expects one complete
218
+ * tool_result message per assistant tool_use message, not a partial
219
+ * one now and a follow-up later. */
220
+ resultsSoFar: ModelContentBlock[];
221
+ /** One entry per tool call still awaiting a durable decision, keyed
222
+ * by the pendingId its DurableApprover/DurableQuestionHandler handed
223
+ * out — what an incoming resolution actually names. `kind`
224
+ * distinguishes which of the two this is — for 'question', `args` is
225
+ * `{ question, options }` and `reason` is the question text itself
226
+ * (there's no separate ActAuth "reason" for a question the way there
227
+ * is for a gated tool call's decision). */
228
+ outstanding: PendingItem[];
229
+ };
230
+ }
231
+ /** One requested call still awaiting a durable decision — either a gated
232
+ * tool call (`kind: 'approval'`) or a `system_ask_user` call (`kind:
233
+ * 'question'`), unified into one array so a batch mixing both shares
234
+ * exactly one checkpoint (see RunAgentResult.stopReason's own doc
235
+ * comment for why that has to be true). */
236
+ export interface PendingItem {
237
+ kind: 'approval' | 'question';
238
+ toolUseId: string;
239
+ tool: string;
240
+ args: Record<string, unknown>;
241
+ pendingId: string;
242
+ reason: string;
137
243
  }
138
244
  /** Resolves AgentConfig.rules the same way skillsDirs resolves its own
139
245
  * default: an inline array is used as-is (full 3-segment scopePattern,
@@ -180,3 +286,25 @@ export declare function loadDefaultTools(config: AgentConfig): Promise<ToolDefin
180
286
  * elsewhere could. */
181
287
  export declare function loadSubagentAsTools(config: AgentConfig): Promise<ToolDefinition[]>;
182
288
  export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
289
+ /** Continues a turn durably paused on RunAgentResult.stopReason ===
290
+ * 'pending_approval' — see HUMAN_IN_THE_LOOP.md for the full design.
291
+ * `history` is durable session history including the dangling assistant
292
+ * tool_use message the pause left behind (exactly what a caller's
293
+ * SessionStore.getHistory/withSession already returns — this needs no
294
+ * special resumed-session handling, since that message is real, already
295
+ * on disk). `resolution` is the *complete* set of tool_result blocks
296
+ * answering that message — every entry from a resolved TurnCheckpoint's
297
+ * own resultsSoFar, once its outstanding is empty; assembling that is the
298
+ * caller's job (core/durable-approvals.ts's own CheckpointStore only
299
+ * stores it, it doesn't execute tools or know when a batch is complete —
300
+ * see that module's own doc comment), not something this function
301
+ * derives on its own.
302
+ *
303
+ * Pushes `resolution` as the completing message and picks the loop back
304
+ * up from there — a fresh model call, now with the full picture, exactly
305
+ * like a synchronous 'ask' would have continued if it hadn't needed to
306
+ * pause at all. Can itself return with stopReason 'pending_approval'
307
+ * again (the model's next round of tool calls hits another durably-gated
308
+ * one), 'denied', 'max_turns', or a genuine finish — runLoop handles all
309
+ * of those uniformly regardless of how this turn started. */
310
+ export declare function resumeAgent(config: AgentConfig, modelCall: ModelCall, history: Message[], resolution: ModelContentBlock[], options?: RunAgentOptions): Promise<RunAgentResult>;
@@ -16,7 +16,9 @@ import { Recovery } from './recovery.js';
16
16
  import { loadAgentModule } from './discover-agents.js';
17
17
  import { agentAsTool } from './agent-as-tool.js';
18
18
  import { loadGatewayToolsFromDir } from './gateway-tools.js';
19
- import { systemTools, createAskUserTool } from './system-tools/index.js';
19
+ import { systemTools, createAskUserTool, CliQuestionHandler, isDurableQuestionHandler } from './system-tools/index.js';
20
+ import { resolveHttpNotifier } from './http-notifier.js';
21
+ import { CRASH_RECOVERY_CONTINUATION } from './session-store.js';
20
22
  export { systemTools } from './system-tools/index.js';
21
23
  // Resolved relative to *this file's own location* (via import.meta.url),
22
24
  // not process.cwd() — the same reasoning core/agent-registry.ts's own
@@ -58,7 +60,7 @@ function flattenForBudget(message) {
58
60
  return { role: message.role, content: message.content };
59
61
  const text = message.content
60
62
  .map((block) => {
61
- if (block.type === 'text')
63
+ if (block.type === 'text' || block.type === 'thinking')
62
64
  return block.text ?? '';
63
65
  if (block.type === 'tool_use')
64
66
  return `[tool_use ${block.name}(${JSON.stringify(block.input)})]`;
@@ -245,7 +247,7 @@ export async function loadSubagentAsTools(config) {
245
247
  }
246
248
  return tools;
247
249
  }
248
- export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
250
+ async function buildTurnContext(config, modelCall, options) {
249
251
  const log = options.onEvent ?? (() => { });
250
252
  // tenant: resolved by the caller (adapters/http.ts calls
251
253
  // AgentConfig.tenantFor with the request's headers/body; every other
@@ -305,63 +307,36 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
305
307
  const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
306
308
  const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
307
309
  const rules = loadRules(config);
308
- const gate = new Gate(rules, config.approver ?? options.approver ?? new ConsoleApprover());
310
+ // AgentConfig.httpNotifier only ever stands in for the http channel —
311
+ // see its own doc comment for why cli/http_stream never consult it at
312
+ // all, not even as a fallback.
313
+ const httpNotifier = resolveHttpNotifier(config);
314
+ // httpNotifier.approver, when the http channel has one, wins outright
315
+ // over options.approver — the same "the agent's own explicit choice for
316
+ // this channel beats whatever the adapter would otherwise default to"
317
+ // precedent a channel-keyed override always had here. This ordering
318
+ // matters in practice, not just on paper: adapters/http.ts's own plain
319
+ // /messages route always passes *some* options.approver (its own
320
+ // deployment-wide durable default, or a live tracked one) — checking
321
+ // httpNotifier first is what lets a single agent's own httpNotifier
322
+ // actually take effect there, rather than being silently shadowed by
323
+ // that per-deployment default every time.
324
+ const approver = (options.channel === 'http' ? httpNotifier.approver : undefined) ?? options.approver ?? new ConsoleApprover();
325
+ const gate = new Gate(rules, approver);
326
+ // Exact mirror of approver's own resolution above, same reasoning and
327
+ // same ordering (httpNotifier checked before options.questionHandler,
328
+ // not after — see approver's own comment for why that order matters in
329
+ // practice, not just on paper) — see RunAgentOptions.questionHandler's
330
+ // own doc comment for the one place this still isn't a full mirror of
331
+ // approver (only the durable branch below actually reads this; the live
332
+ // branch keeps resolving independently, via onQuestionPending).
333
+ const questionHandler = (options.channel === 'http' ? httpNotifier.questionHandler : undefined) ?? options.questionHandler ?? new CliQuestionHandler();
309
334
  // No explicit isSafeTool: fall back to each called tool's own `safe`
310
335
  // flag (looked up by name) rather than defaulting every tool to unsafe
311
336
  // outright — see ToolDefinition.safe's own doc comment for why this is
312
337
  // the less powerful of the two (name-only, no per-call nuance).
313
338
  const isSafeTool = config.isSafeTool ?? ((call) => tools.some((t) => t.name === call.name && t.safe === true));
314
339
  const toolLane = new ToolLane({ isSafe: isSafeTool });
315
- let messages = [...history, { role: 'user', content: userMessage }];
316
- // Tracked in parallel with `messages`, but never rewritten wholesale by
317
- // recovery the way `messages` is — this is the actual, authoritative
318
- // answer to "what did this turn add," independent of how many times (if
319
- // any) the head of `messages` got drained/summarized along the way. See
320
- // pushMessage() below and the reconciliation inside onPromptTooLong.
321
- let newMessages = [{ role: 'user', content: userMessage }];
322
- function pushMessage(message) {
323
- messages.push(message);
324
- newMessages.push(message);
325
- }
326
- const recovery = new Recovery({
327
- onPromptTooLong: async (currentMessages) => {
328
- // newMessages (this turn's own content — not durably stored
329
- // anywhere else yet) must never be handed to the compactor at all.
330
- // An earlier version of this reconciliation tried to recover
331
- // newMessages *after* compaction by reusing whatever the
332
- // compactor's synthetic head contained — that's unsound: its cheap
333
- // "drain" stage doesn't produce a head at all, it just deletes old
334
- // messages outright, trusting there's a durable copy elsewhere.
335
- // That trust is only valid for the *prior* portion; only that
336
- // portion is safe to compact at all.
337
- const priorCount = currentMessages.length - newMessages.length;
338
- const priorPortion = currentMessages.slice(0, priorCount);
339
- const result = await compactor.recover(priorPortion.map(flattenForBudget));
340
- log({ type: 'prompt:compaction', from: currentMessages.length, to: result.messages.length + newMessages.length });
341
- if (result.action === 'unchanged')
342
- return currentMessages;
343
- // Same tail-preservation reuse as before, just scoped to
344
- // priorPortion alone now — newMessages is appended back whole,
345
- // always, regardless of how large it's grown this turn.
346
- const preservedTailCount = Math.min(tailMessages, priorPortion.length);
347
- const structuredTail = priorPortion.slice(priorPortion.length - preservedTailCount);
348
- const newHead = result.messages.slice(0, result.messages.length - preservedTailCount);
349
- const recovered = [...newHead, ...structuredTail, ...newMessages];
350
- // Recovery.call() only ever returns {value, recoveries, truncated} —
351
- // it never hands back whatever `currentMessages` became after a
352
- // retry, so recovery would otherwise only ever affect the one
353
- // retried call. Reassigning the outer `messages` binding here (this
354
- // hook is the only place that has both the recovered array and a
355
- // reason to persist it) is what makes recovery durable: every
356
- // subsequent budgetTracker.check() in this loop, and the `history`
357
- // this function eventually returns, both see the compacted
358
- // conversation from this point on — not the original, ever-growing
359
- // one. newMessages itself needs no reconciliation at all — it was
360
- // never part of what could be compacted away.
361
- messages = recovered;
362
- return recovered;
363
- },
364
- });
365
340
  const toolsByName = new Map(tools.map((t) => [t.name, t]));
366
341
  // Object identity, not name — an agent that overrides a system tool's
367
342
  // name with its own ToolDefinition (dedupeToolsByName's own "config
@@ -412,6 +387,81 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
412
387
  // (or ping-ponging) tool calls forever — checked before spending a model
413
388
  // call on the (maxTurns + 1)-th turn, not after.
414
389
  const maxTurns = config.maxTurns ?? 25;
390
+ return {
391
+ modelCall,
392
+ log,
393
+ scope,
394
+ sessionId: options.sessionId,
395
+ skillGarden,
396
+ toolsByName,
397
+ systemToolInstances,
398
+ toolSchemas,
399
+ systemPrompt,
400
+ budgetTracker,
401
+ compactor,
402
+ gate,
403
+ toolLane,
404
+ maxTurns,
405
+ tailMessages,
406
+ askUserTool,
407
+ questionHandler,
408
+ httpNotifier,
409
+ };
410
+ }
411
+ /** The turn's actual ReAct loop — shared by runAgent (a fresh turn, its
412
+ * own starterMessage is the user's own message) and resumeAgent (a
413
+ * paused turn's completing tool_result message as starterMessage,
414
+ * picking up right where run-agent.ts's own bucket-then-execute left
415
+ * off — see RunAgentResult.stopReason's own 'pending_approval' doc
416
+ * comment). `messages`/`newMessages` are owned by the caller (built
417
+ * fresh in each case) but mutated here via pushMessage, same as the
418
+ * single function this was split out of always did. */
419
+ async function runLoop(ctx, messages, newMessages, starterMessage) {
420
+ const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler } = ctx;
421
+ function pushMessage(message) {
422
+ messages.push(message);
423
+ newMessages.push(message);
424
+ }
425
+ const recovery = new Recovery({
426
+ onPromptTooLong: async (currentMessages) => {
427
+ // newMessages (this turn's own content — not durably stored
428
+ // anywhere else yet) must never be handed to the compactor at all.
429
+ // An earlier version of this reconciliation tried to recover
430
+ // newMessages *after* compaction by reusing whatever the
431
+ // compactor's synthetic head contained — that's unsound: its cheap
432
+ // "drain" stage doesn't produce a head at all, it just deletes old
433
+ // messages outright, trusting there's a durable copy elsewhere.
434
+ // That trust is only valid for the *prior* portion; only that
435
+ // portion is safe to compact at all.
436
+ const priorCount = currentMessages.length - newMessages.length;
437
+ const priorPortion = currentMessages.slice(0, priorCount);
438
+ const result = await compactor.recover(priorPortion.map(flattenForBudget));
439
+ log({ type: 'prompt:compaction', from: currentMessages.length, to: result.messages.length + newMessages.length });
440
+ if (result.action === 'unchanged')
441
+ return currentMessages;
442
+ // Same tail-preservation reuse as before, just scoped to
443
+ // priorPortion alone now — newMessages is appended back whole,
444
+ // always, regardless of how large it's grown this turn.
445
+ const preservedTailCount = Math.min(tailMessages, priorPortion.length);
446
+ const structuredTail = priorPortion.slice(priorPortion.length - preservedTailCount);
447
+ const newHead = result.messages.slice(0, result.messages.length - preservedTailCount);
448
+ const recovered = [...newHead, ...structuredTail, ...newMessages];
449
+ // Recovery.call() only ever returns {value, recoveries, truncated} —
450
+ // it never hands back whatever `currentMessages` became after a
451
+ // retry, so recovery would otherwise only ever affect the one
452
+ // retried call. Reassigning the outer `messages` binding here (this
453
+ // hook is the only place that has both the recovered array and a
454
+ // reason to persist it) is what makes recovery durable: every
455
+ // subsequent budgetTracker.check() in this loop, and the `history`
456
+ // this function eventually returns, both see the compacted
457
+ // conversation from this point on — not the original, ever-growing
458
+ // one. newMessages itself needs no reconciliation at all — it was
459
+ // never part of what could be compacted away.
460
+ messages = recovered;
461
+ return recovered;
462
+ },
463
+ });
464
+ pushMessage(starterMessage);
415
465
  let turn = 0;
416
466
  for (;;) {
417
467
  turn++;
@@ -460,6 +510,15 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
460
510
  const resultBlocks = [];
461
511
  const approvedCalls = [];
462
512
  const deniedTools = [];
513
+ // A DurableApprover/DurableQuestionHandler deferred this call to a
514
+ // durable record instead of resolving it inline (see
515
+ // HUMAN_IN_THE_LOOP.md) — collected separately from
516
+ // approvedCalls/deniedTools since it's neither run now nor denied now.
517
+ // No tool_result is pushed for these yet; the batch's post-loop
518
+ // handling below decides what happens to them. One shared array for
519
+ // both kinds (not two parallel ones) — see PendingItem's own doc
520
+ // comment for why a mixed batch has to share one checkpoint.
521
+ const pending = [];
463
522
  // Keyed by tool_use id — approvedCalls (a toollane LaneCall) has no
464
523
  // room for extra fields of its own, and toolLane.run's own results
465
524
  // only carry `id`/`name`/status, not anything about the *decision*
@@ -481,6 +540,38 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
481
540
  resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
482
541
  continue;
483
542
  }
543
+ // Checked before the systemToolInstances bypass just below (which
544
+ // would otherwise auto-run ask_user's own execute() straight away,
545
+ // in-process) — when the resolved QuestionHandler for this channel
546
+ // is durable (isDurableQuestionHandler — see its own doc comment,
547
+ // mirroring Gate's isDurableApprover), a system_ask_user call gets
548
+ // the exact same bucket-then-execute treatment a durably-gated tool
549
+ // call gets from gate.evaluate() below, never actually invoking the
550
+ // tool's own execute()/onPending live path at all. See PendingItem's
551
+ // own doc comment for why this shares `pending` with approvals
552
+ // rather than its own separate array. Identity, not name
553
+ // (`toolsByName.get(...) === askUserTool`, not `block.name ===
554
+ // 'system_ask_user'`) — same reasoning the systemToolInstances check
555
+ // just below already uses: an agent that deliberately overrode the
556
+ // name with its own ToolDefinition must still fall through to
557
+ // gate.evaluate() like any other tool, not get silently swallowed
558
+ // into the durable-question bucket just because the name happens to
559
+ // match. The *live* case (questionHandler resolved to something,
560
+ // just not durable — including the hard CliQuestionHandler default)
561
+ // deliberately falls through to the systemToolInstances bypass below
562
+ // unchanged — see RunAgentOptions.questionHandler's own doc comment
563
+ // for why that half isn't wired to this resolved value yet.
564
+ if (toolsByName.get(block.name) === askUserTool && isDurableQuestionHandler(questionHandler)) {
565
+ const question = String(block.input?.question ?? '');
566
+ const questionOptions = Array.isArray(block.input?.options) ? block.input.options.map(String) : undefined;
567
+ const { pendingId } = questionHandler.notifyPendingQuestion(question, questionOptions, scope.agent, sessionId);
568
+ // No 'tool:started'/'tool:result' here — same reasoning the
569
+ // durably-gated 'pending' branch below has: nothing to report
570
+ // until a resolve call answers it, later, possibly in a
571
+ // different process.
572
+ pending.push({ kind: 'question', toolUseId: block.id, tool: block.name, args: { question, options: questionOptions }, pendingId, reason: question });
573
+ continue;
574
+ }
484
575
  // See systemToolInstances' own doc comment above — bypasses
485
576
  // gate.evaluate entirely, not just auto-approves through it, since
486
577
  // ask_user itself can be the approver's own only way to ask a human
@@ -520,6 +611,15 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
520
611
  execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
521
612
  });
522
613
  }
614
+ else if (decision.decision === 'pending') {
615
+ // No 'tool:started'/'tool:result' here — unlike a live 'ask',
616
+ // this call isn't running and isn't decided yet; it genuinely
617
+ // has nothing to report until a resolve call answers it, later,
618
+ // possibly in a different process. See 'loop:pending_approval'
619
+ // below (once every block in the batch is known) for the live
620
+ // signal this batch needed a durable decision.
621
+ pending.push({ kind: 'approval', toolUseId: block.id, tool: block.name, args: block.input ?? {}, pendingId: decision.pendingId, reason: decision.reason });
622
+ }
523
623
  else {
524
624
  // Every requested tool gets exactly one tool_result back — a
525
625
  // denied call is not simply dropped, or the model has no way to
@@ -569,6 +669,26 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
569
669
  // just above.
570
670
  log({ type: 'tool:result', id: call.id, tool: call.name, args: approvedMetaById.get(call.id)?.args, detailText: skipReason, statusText: 'Skipped.' });
571
671
  }
672
+ // A pending call/question closes exactly the same way an
673
+ // approved-but-never-run one does above — see HUMAN_IN_THE_LOOP.md's
674
+ // own "denial closes the checkpoint immediately" semantics: since
675
+ // this never actually becomes a checkpoint (the batch is resolved
676
+ // synchronously right here), there's nothing durable left dangling —
677
+ // only the DurableApprover's/DurableQuestionHandler's own pendingId
678
+ // record, orphaned but harmless: a resolve call against it later
679
+ // finds no checkpoint at all (never created) and no-ops, same as any
680
+ // other unknown pendingId.
681
+ for (const pendingItem of pending) {
682
+ log({ type: 'loop:skipped', name: pendingItem.tool, deniedTools });
683
+ const skipReason = `a sibling tool call in this turn (${deniedTools.join(', ')}) was denied`;
684
+ resultBlocks.push({
685
+ type: 'tool_result',
686
+ tool_use_id: pendingItem.toolUseId,
687
+ content: `skipped: ${skipReason}`,
688
+ is_error: true,
689
+ });
690
+ log({ type: 'tool:result', id: pendingItem.toolUseId, tool: pendingItem.tool, args: pendingItem.args, detailText: skipReason, statusText: 'Skipped.' });
691
+ }
572
692
  }
573
693
  else {
574
694
  // result.id is the same id LaneCall.id was given above — the exact
@@ -604,6 +724,30 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
604
724
  statusText: result.status === 'fulfilled' ? 'Approved.' : 'Error.',
605
725
  });
606
726
  }
727
+ // At least one call this batch is durably pending — resultBlocks is
728
+ // incomplete (missing an entry for every pending item) and must
729
+ // NOT be pushed as the completing tool_result message: a real model
730
+ // API expects exactly one, complete, tool_result per tool_use
731
+ // message, not a partial one now and a follow-up later. Stop here
732
+ // instead — newMessages already ends at the dangling assistant
733
+ // tool_use message pushed above, the same crash-recovery shape
734
+ // session-store.ts's own hasUnresolvedToolCall already detects (see
735
+ // RunAgentResult.stopReason's own doc comment). Turning this into a
736
+ // durable TurnCheckpoint is the caller's job — this loop does no I/O.
737
+ if (pending.length > 0) {
738
+ // A mixed batch (both kinds present) reports 'pending_approval' —
739
+ // see RunAgentResult.stopReason's own doc comment for why that's
740
+ // the right call, not an arbitrary tie-break.
741
+ const stopReason = pending.some((p) => p.kind === 'approval') ? 'pending_approval' : 'pending_question';
742
+ log({ type: stopReason === 'pending_question' ? 'loop:pending_question' : 'loop:pending_approval', pendingIds: pending.map((p) => p.pendingId) });
743
+ return {
744
+ text: `Stopped — awaiting durable ${stopReason === 'pending_question' ? 'answer' : 'approval'} for: ${pending.map((p) => p.tool).join(', ')}.`,
745
+ history: messages,
746
+ newMessages,
747
+ stopReason,
748
+ pending: { resultsSoFar: resultBlocks, outstanding: pending },
749
+ };
750
+ }
607
751
  }
608
752
  pushMessage({ role: 'user', content: resultBlocks });
609
753
  // Stop here, don't loop back for another model call — same reasoning
@@ -620,3 +764,101 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
620
764
  }
621
765
  }
622
766
  }
767
+ /** Invokes an AgentConfig.onRunStart/onRunFinish callback per its own
768
+ * never-awaited contract (see AgentConfig's own doc comments on each) —
769
+ * whether `call()` throws synchronously or returns a rejected Promise,
770
+ * the failure is logged, never surfaced to (or awaited by) the loop that
771
+ * triggered it. One shared implementation for both hooks and both of
772
+ * runAgent/resumeAgent's own call sites, rather than four copies of the
773
+ * same try/catch-and-maybe-.catch dance. */
774
+ function fireLifecycleHook(label, agentName, call) {
775
+ try {
776
+ const result = call();
777
+ if (result && typeof result.catch === 'function') {
778
+ result.catch((err) => console.error(`[loopengine] ${label} threw for agent '${agentName}':`, err));
779
+ }
780
+ }
781
+ catch (err) {
782
+ console.error(`[loopengine] ${label} threw for agent '${agentName}':`, err);
783
+ }
784
+ }
785
+ /** `cli` and `http_stream` both already deliver the start/finish signal
786
+ * to whoever's actually waiting, synchronously, as part of that channel's
787
+ * own normal response — a terminal that just ran the command, or an SSE
788
+ * connection that already carries its own `session`/`done` events on the
789
+ * exact same stream. Firing onRunStart/onRunFinish there too would just
790
+ * be a second, redundant copy of information the caller already has, so
791
+ * neither hook is invoked for those two channels — not left to each
792
+ * implementation to notice and filter out itself the way `channel` in
793
+ * the fired context still lets you filter *within* the channels this
794
+ * does fire for (`http`, or no channel at all — a bespoke script has no
795
+ * such guaranteed synchronous delivery to assume either way, so it still
796
+ * fires there). */
797
+ function shouldFireLifecycleHooks(channel) {
798
+ return channel !== 'cli' && channel !== 'http_stream';
799
+ }
800
+ export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
801
+ const ctx = await buildTurnContext(config, modelCall, options);
802
+ // config.onRunStart, if set, still wins outright over notifier's own
803
+ // derived hook — see NotifierConfig's own doc comment on precedence.
804
+ const onRunStart = config.onRunStart ?? (options.channel === 'http' ? ctx.httpNotifier.onRunStart : undefined);
805
+ if (onRunStart && shouldFireLifecycleHooks(options.channel)) {
806
+ fireLifecycleHook('onRunStart', config.name, () => onRunStart({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, trigger: 'message' }));
807
+ }
808
+ const result = await runLoop(ctx, [...history], [], { role: 'user', content: userMessage });
809
+ const onRunFinish = config.onRunFinish ?? (options.channel === 'http' ? ctx.httpNotifier.onRunFinish : undefined);
810
+ if (onRunFinish && !result.pending && shouldFireLifecycleHooks(options.channel)) {
811
+ fireLifecycleHook('onRunFinish', config.name, () => onRunFinish({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, text: result.text, stopReason: result.stopReason }));
812
+ }
813
+ return result;
814
+ }
815
+ /** Continues a turn durably paused on RunAgentResult.stopReason ===
816
+ * 'pending_approval' — see HUMAN_IN_THE_LOOP.md for the full design.
817
+ * `history` is durable session history including the dangling assistant
818
+ * tool_use message the pause left behind (exactly what a caller's
819
+ * SessionStore.getHistory/withSession already returns — this needs no
820
+ * special resumed-session handling, since that message is real, already
821
+ * on disk). `resolution` is the *complete* set of tool_result blocks
822
+ * answering that message — every entry from a resolved TurnCheckpoint's
823
+ * own resultsSoFar, once its outstanding is empty; assembling that is the
824
+ * caller's job (core/durable-approvals.ts's own CheckpointStore only
825
+ * stores it, it doesn't execute tools or know when a batch is complete —
826
+ * see that module's own doc comment), not something this function
827
+ * derives on its own.
828
+ *
829
+ * Pushes `resolution` as the completing message and picks the loop back
830
+ * up from there — a fresh model call, now with the full picture, exactly
831
+ * like a synchronous 'ask' would have continued if it hadn't needed to
832
+ * pause at all. Can itself return with stopReason 'pending_approval'
833
+ * again (the model's next round of tool calls hits another durably-gated
834
+ * one), 'denied', 'max_turns', or a genuine finish — runLoop handles all
835
+ * of those uniformly regardless of how this turn started. */
836
+ export async function resumeAgent(config, modelCall, history, resolution, options = {}) {
837
+ const ctx = await buildTurnContext(config, modelCall, options);
838
+ const onRunStart = config.onRunStart ?? (options.channel === 'http' ? ctx.httpNotifier.onRunStart : undefined);
839
+ if (onRunStart && shouldFireLifecycleHooks(options.channel)) {
840
+ fireLifecycleHook('onRunStart', config.name, () => onRunStart({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, trigger: 'resolution' }));
841
+ }
842
+ // sessions.withSession's own load (adapters/http.ts's respondAfterResolution)
843
+ // runs through the exact same hasUnresolvedToolCall check real crash
844
+ // recovery uses — a durable pause deliberately leaves the identical
845
+ // dangling-tool_use shape (see HUMAN_IN_THE_LOOP.md's own "same
846
+ // crash-recovery shape... entered on purpose"), so session-store.ts has
847
+ // no way to tell the two apart and injects its own synthetic
848
+ // CRASH_RECOVERY_CONTINUATION message regardless. Only resumeAgent
849
+ // itself knows, by the fact that it's being called at all with a real
850
+ // resolution in hand, that this wasn't a crash — so it's the only place
851
+ // that can safely strip that message back out before it ends up wedged
852
+ // between the assistant's tool_calls message and the tool_result
853
+ // answering it, which every OpenAI-wire-compatible provider rejects
854
+ // outright (an assistant message with tool_calls must be immediately
855
+ // followed by one tool message per call, nothing in between).
856
+ const lastMessage = history[history.length - 1];
857
+ const effectiveHistory = lastMessage?.role === 'user' && lastMessage.content === CRASH_RECOVERY_CONTINUATION ? history.slice(0, -1) : history;
858
+ const result = await runLoop(ctx, [...effectiveHistory], [], { role: 'user', content: resolution });
859
+ const onRunFinish = config.onRunFinish ?? (options.channel === 'http' ? ctx.httpNotifier.onRunFinish : undefined);
860
+ if (onRunFinish && !result.pending && shouldFireLifecycleHooks(options.channel)) {
861
+ fireLifecycleHook('onRunFinish', config.name, () => onRunFinish({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, text: result.text, stopReason: result.stopReason }));
862
+ }
863
+ return result;
864
+ }
@@ -42,6 +42,7 @@ export declare class KeyedMutex {
42
42
  * work across all keys has settled. */
43
43
  size(): number;
44
44
  }
45
+ export declare const CRASH_RECOVERY_CONTINUATION = "[session resumed after an interruption \u2014 the assistant had requested tool calls but the process stopped before any results were recorded; treat those tools as not having run]";
45
46
  /** Wraps a SessionKnit log behind the flat load/run/append contract
46
47
  * runAgent's callers use. Shared by both the file- and Redis-backed
47
48
  * stores below — only the Storage and the lock differ. */