loopengine 0.0.21 → 0.1.3

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 +291 -8
  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 +316 -57
  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
@@ -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
@@ -31,6 +33,23 @@ export { systemTools } from './system-tools/index.js';
31
33
  // tools/index.ts is real code that only exists compiled, at a different
32
34
  // relative location, once dist/ is what's actually running.
33
35
  const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
36
+ // agentsRootDir above is right when agents/ is compiled alongside core/
37
+ // into the same dist/ tree (this repo's own reference agents; also this
38
+ // repo's own Dockerfile, which copies dist/agents/**/*.js this way) — but
39
+ // a project that only *depends on* the published loopengine package has
40
+ // no dist/agents/<name> inside node_modules/loopengine at all, just its
41
+ // own agents/<name>/ at the project root, found only via process.cwd(),
42
+ // the same way loadRules/skillsDirs already resolve their own (data, not
43
+ // code) defaults. Tried first since it's this repo's own existing,
44
+ // tested behavior; process.cwd() only kicks in when nothing's built
45
+ // there to find — so this repo's own agents are unaffected either way.
46
+ function resolveAgentCodeDir(name, subpath, indexRequired) {
47
+ const built = join(agentsRootDir, name, subpath);
48
+ const builtExists = indexRequired
49
+ ? existsSync(join(built, 'index.ts')) || existsSync(join(built, 'index.js'))
50
+ : existsSync(built);
51
+ return builtExists ? built : join(process.cwd(), 'agents', name, subpath);
52
+ }
34
53
  // Same "resolved relative to this file's own location" reasoning as
35
54
  // agentsRootDir above, but system-skills/ is a same-level sibling of
36
55
  // run-agent.ts itself (both live directly under core/ — see package.json's
@@ -58,7 +77,7 @@ function flattenForBudget(message) {
58
77
  return { role: message.role, content: message.content };
59
78
  const text = message.content
60
79
  .map((block) => {
61
- if (block.type === 'text')
80
+ if (block.type === 'text' || block.type === 'thinking')
62
81
  return block.text ?? '';
63
82
  if (block.type === 'tool_use')
64
83
  return `[tool_use ${block.name}(${JSON.stringify(block.input)})]`;
@@ -155,7 +174,7 @@ async function loadToolsFromDir(toolsDir) {
155
174
  return [];
156
175
  }
157
176
  export async function loadDefaultTools(config) {
158
- return loadToolsFromDir(join(agentsRootDir, config.name, 'tools'));
177
+ return loadToolsFromDir(resolveAgentCodeDir(config.name, 'tools', true));
159
178
  }
160
179
  /** Every other folder-form default in this file (loadDefaultTools,
161
180
  * loadRules, the skillsDirs default below) resolves against
@@ -227,7 +246,7 @@ async function resolveSubagentConfig(config, dir) {
227
246
  * this can't cycle the way a hand-wired agentAsTool(getEntry(...)) call
228
247
  * elsewhere could. */
229
248
  export async function loadSubagentAsTools(config) {
230
- const subagentsDir = join(agentsRootDir, config.name, 'subagents');
249
+ const subagentsDir = resolveAgentCodeDir(config.name, 'subagents', false);
231
250
  if (!existsSync(subagentsDir))
232
251
  return [];
233
252
  const tools = [];
@@ -245,7 +264,7 @@ export async function loadSubagentAsTools(config) {
245
264
  }
246
265
  return tools;
247
266
  }
248
- export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
267
+ async function buildTurnContext(config, modelCall, options) {
249
268
  const log = options.onEvent ?? (() => { });
250
269
  // tenant: resolved by the caller (adapters/http.ts calls
251
270
  // AgentConfig.tenantFor with the request's headers/body; every other
@@ -278,7 +297,7 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
278
297
  askUserTool,
279
298
  ...(config.tools ?? (await loadDefaultTools(config))),
280
299
  ...(await loadSubagentAsTools(config)),
281
- ...(await loadGatewayToolsFromDir(join(agentsRootDir, config.name))),
300
+ ...(await loadGatewayToolsFromDir(join(process.cwd(), 'agents', config.name))),
282
301
  ]);
283
302
  // Omitted entirely (undefined): default to this agent's own
284
303
  // agents/<name>/skills — the folder-form convention every agent in
@@ -305,63 +324,36 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
305
324
  const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
306
325
  const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
307
326
  const rules = loadRules(config);
308
- const gate = new Gate(rules, config.approver ?? options.approver ?? new ConsoleApprover());
327
+ // AgentConfig.httpNotifier only ever stands in for the http channel —
328
+ // see its own doc comment for why cli/http_stream never consult it at
329
+ // all, not even as a fallback.
330
+ const httpNotifier = resolveHttpNotifier(config);
331
+ // httpNotifier.approver, when the http channel has one, wins outright
332
+ // over options.approver — the same "the agent's own explicit choice for
333
+ // this channel beats whatever the adapter would otherwise default to"
334
+ // precedent a channel-keyed override always had here. This ordering
335
+ // matters in practice, not just on paper: adapters/http.ts's own plain
336
+ // /messages route always passes *some* options.approver (its own
337
+ // deployment-wide durable default, or a live tracked one) — checking
338
+ // httpNotifier first is what lets a single agent's own httpNotifier
339
+ // actually take effect there, rather than being silently shadowed by
340
+ // that per-deployment default every time.
341
+ const approver = (options.channel === 'http' ? httpNotifier.approver : undefined) ?? options.approver ?? new ConsoleApprover();
342
+ const gate = new Gate(rules, approver);
343
+ // Exact mirror of approver's own resolution above, same reasoning and
344
+ // same ordering (httpNotifier checked before options.questionHandler,
345
+ // not after — see approver's own comment for why that order matters in
346
+ // practice, not just on paper) — see RunAgentOptions.questionHandler's
347
+ // own doc comment for the one place this still isn't a full mirror of
348
+ // approver (only the durable branch below actually reads this; the live
349
+ // branch keeps resolving independently, via onQuestionPending).
350
+ const questionHandler = (options.channel === 'http' ? httpNotifier.questionHandler : undefined) ?? options.questionHandler ?? new CliQuestionHandler();
309
351
  // No explicit isSafeTool: fall back to each called tool's own `safe`
310
352
  // flag (looked up by name) rather than defaulting every tool to unsafe
311
353
  // outright — see ToolDefinition.safe's own doc comment for why this is
312
354
  // the less powerful of the two (name-only, no per-call nuance).
313
355
  const isSafeTool = config.isSafeTool ?? ((call) => tools.some((t) => t.name === call.name && t.safe === true));
314
356
  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
357
  const toolsByName = new Map(tools.map((t) => [t.name, t]));
366
358
  // Object identity, not name — an agent that overrides a system tool's
367
359
  // name with its own ToolDefinition (dedupeToolsByName's own "config
@@ -412,6 +404,81 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
412
404
  // (or ping-ponging) tool calls forever — checked before spending a model
413
405
  // call on the (maxTurns + 1)-th turn, not after.
414
406
  const maxTurns = config.maxTurns ?? 25;
407
+ return {
408
+ modelCall,
409
+ log,
410
+ scope,
411
+ sessionId: options.sessionId,
412
+ skillGarden,
413
+ toolsByName,
414
+ systemToolInstances,
415
+ toolSchemas,
416
+ systemPrompt,
417
+ budgetTracker,
418
+ compactor,
419
+ gate,
420
+ toolLane,
421
+ maxTurns,
422
+ tailMessages,
423
+ askUserTool,
424
+ questionHandler,
425
+ httpNotifier,
426
+ };
427
+ }
428
+ /** The turn's actual ReAct loop — shared by runAgent (a fresh turn, its
429
+ * own starterMessage is the user's own message) and resumeAgent (a
430
+ * paused turn's completing tool_result message as starterMessage,
431
+ * picking up right where run-agent.ts's own bucket-then-execute left
432
+ * off — see RunAgentResult.stopReason's own 'pending_approval' doc
433
+ * comment). `messages`/`newMessages` are owned by the caller (built
434
+ * fresh in each case) but mutated here via pushMessage, same as the
435
+ * single function this was split out of always did. */
436
+ async function runLoop(ctx, messages, newMessages, starterMessage) {
437
+ const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler } = ctx;
438
+ function pushMessage(message) {
439
+ messages.push(message);
440
+ newMessages.push(message);
441
+ }
442
+ const recovery = new Recovery({
443
+ onPromptTooLong: async (currentMessages) => {
444
+ // newMessages (this turn's own content — not durably stored
445
+ // anywhere else yet) must never be handed to the compactor at all.
446
+ // An earlier version of this reconciliation tried to recover
447
+ // newMessages *after* compaction by reusing whatever the
448
+ // compactor's synthetic head contained — that's unsound: its cheap
449
+ // "drain" stage doesn't produce a head at all, it just deletes old
450
+ // messages outright, trusting there's a durable copy elsewhere.
451
+ // That trust is only valid for the *prior* portion; only that
452
+ // portion is safe to compact at all.
453
+ const priorCount = currentMessages.length - newMessages.length;
454
+ const priorPortion = currentMessages.slice(0, priorCount);
455
+ const result = await compactor.recover(priorPortion.map(flattenForBudget));
456
+ log({ type: 'prompt:compaction', from: currentMessages.length, to: result.messages.length + newMessages.length });
457
+ if (result.action === 'unchanged')
458
+ return currentMessages;
459
+ // Same tail-preservation reuse as before, just scoped to
460
+ // priorPortion alone now — newMessages is appended back whole,
461
+ // always, regardless of how large it's grown this turn.
462
+ const preservedTailCount = Math.min(tailMessages, priorPortion.length);
463
+ const structuredTail = priorPortion.slice(priorPortion.length - preservedTailCount);
464
+ const newHead = result.messages.slice(0, result.messages.length - preservedTailCount);
465
+ const recovered = [...newHead, ...structuredTail, ...newMessages];
466
+ // Recovery.call() only ever returns {value, recoveries, truncated} —
467
+ // it never hands back whatever `currentMessages` became after a
468
+ // retry, so recovery would otherwise only ever affect the one
469
+ // retried call. Reassigning the outer `messages` binding here (this
470
+ // hook is the only place that has both the recovered array and a
471
+ // reason to persist it) is what makes recovery durable: every
472
+ // subsequent budgetTracker.check() in this loop, and the `history`
473
+ // this function eventually returns, both see the compacted
474
+ // conversation from this point on — not the original, ever-growing
475
+ // one. newMessages itself needs no reconciliation at all — it was
476
+ // never part of what could be compacted away.
477
+ messages = recovered;
478
+ return recovered;
479
+ },
480
+ });
481
+ pushMessage(starterMessage);
415
482
  let turn = 0;
416
483
  for (;;) {
417
484
  turn++;
@@ -460,6 +527,15 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
460
527
  const resultBlocks = [];
461
528
  const approvedCalls = [];
462
529
  const deniedTools = [];
530
+ // A DurableApprover/DurableQuestionHandler deferred this call to a
531
+ // durable record instead of resolving it inline (see
532
+ // HUMAN_IN_THE_LOOP.md) — collected separately from
533
+ // approvedCalls/deniedTools since it's neither run now nor denied now.
534
+ // No tool_result is pushed for these yet; the batch's post-loop
535
+ // handling below decides what happens to them. One shared array for
536
+ // both kinds (not two parallel ones) — see PendingItem's own doc
537
+ // comment for why a mixed batch has to share one checkpoint.
538
+ const pending = [];
463
539
  // Keyed by tool_use id — approvedCalls (a toollane LaneCall) has no
464
540
  // room for extra fields of its own, and toolLane.run's own results
465
541
  // only carry `id`/`name`/status, not anything about the *decision*
@@ -481,6 +557,38 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
481
557
  resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
482
558
  continue;
483
559
  }
560
+ // Checked before the systemToolInstances bypass just below (which
561
+ // would otherwise auto-run ask_user's own execute() straight away,
562
+ // in-process) — when the resolved QuestionHandler for this channel
563
+ // is durable (isDurableQuestionHandler — see its own doc comment,
564
+ // mirroring Gate's isDurableApprover), a system_ask_user call gets
565
+ // the exact same bucket-then-execute treatment a durably-gated tool
566
+ // call gets from gate.evaluate() below, never actually invoking the
567
+ // tool's own execute()/onPending live path at all. See PendingItem's
568
+ // own doc comment for why this shares `pending` with approvals
569
+ // rather than its own separate array. Identity, not name
570
+ // (`toolsByName.get(...) === askUserTool`, not `block.name ===
571
+ // 'system_ask_user'`) — same reasoning the systemToolInstances check
572
+ // just below already uses: an agent that deliberately overrode the
573
+ // name with its own ToolDefinition must still fall through to
574
+ // gate.evaluate() like any other tool, not get silently swallowed
575
+ // into the durable-question bucket just because the name happens to
576
+ // match. The *live* case (questionHandler resolved to something,
577
+ // just not durable — including the hard CliQuestionHandler default)
578
+ // deliberately falls through to the systemToolInstances bypass below
579
+ // unchanged — see RunAgentOptions.questionHandler's own doc comment
580
+ // for why that half isn't wired to this resolved value yet.
581
+ if (toolsByName.get(block.name) === askUserTool && isDurableQuestionHandler(questionHandler)) {
582
+ const question = String(block.input?.question ?? '');
583
+ const questionOptions = Array.isArray(block.input?.options) ? block.input.options.map(String) : undefined;
584
+ const { pendingId } = questionHandler.notifyPendingQuestion(question, questionOptions, scope.agent, sessionId);
585
+ // No 'tool:started'/'tool:result' here — same reasoning the
586
+ // durably-gated 'pending' branch below has: nothing to report
587
+ // until a resolve call answers it, later, possibly in a
588
+ // different process.
589
+ pending.push({ kind: 'question', toolUseId: block.id, tool: block.name, args: { question, options: questionOptions }, pendingId, reason: question });
590
+ continue;
591
+ }
484
592
  // See systemToolInstances' own doc comment above — bypasses
485
593
  // gate.evaluate entirely, not just auto-approves through it, since
486
594
  // ask_user itself can be the approver's own only way to ask a human
@@ -520,6 +628,15 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
520
628
  execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
521
629
  });
522
630
  }
631
+ else if (decision.decision === 'pending') {
632
+ // No 'tool:started'/'tool:result' here — unlike a live 'ask',
633
+ // this call isn't running and isn't decided yet; it genuinely
634
+ // has nothing to report until a resolve call answers it, later,
635
+ // possibly in a different process. See 'loop:pending_approval'
636
+ // below (once every block in the batch is known) for the live
637
+ // signal this batch needed a durable decision.
638
+ pending.push({ kind: 'approval', toolUseId: block.id, tool: block.name, args: block.input ?? {}, pendingId: decision.pendingId, reason: decision.reason });
639
+ }
523
640
  else {
524
641
  // Every requested tool gets exactly one tool_result back — a
525
642
  // denied call is not simply dropped, or the model has no way to
@@ -569,6 +686,26 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
569
686
  // just above.
570
687
  log({ type: 'tool:result', id: call.id, tool: call.name, args: approvedMetaById.get(call.id)?.args, detailText: skipReason, statusText: 'Skipped.' });
571
688
  }
689
+ // A pending call/question closes exactly the same way an
690
+ // approved-but-never-run one does above — see HUMAN_IN_THE_LOOP.md's
691
+ // own "denial closes the checkpoint immediately" semantics: since
692
+ // this never actually becomes a checkpoint (the batch is resolved
693
+ // synchronously right here), there's nothing durable left dangling —
694
+ // only the DurableApprover's/DurableQuestionHandler's own pendingId
695
+ // record, orphaned but harmless: a resolve call against it later
696
+ // finds no checkpoint at all (never created) and no-ops, same as any
697
+ // other unknown pendingId.
698
+ for (const pendingItem of pending) {
699
+ log({ type: 'loop:skipped', name: pendingItem.tool, deniedTools });
700
+ const skipReason = `a sibling tool call in this turn (${deniedTools.join(', ')}) was denied`;
701
+ resultBlocks.push({
702
+ type: 'tool_result',
703
+ tool_use_id: pendingItem.toolUseId,
704
+ content: `skipped: ${skipReason}`,
705
+ is_error: true,
706
+ });
707
+ log({ type: 'tool:result', id: pendingItem.toolUseId, tool: pendingItem.tool, args: pendingItem.args, detailText: skipReason, statusText: 'Skipped.' });
708
+ }
572
709
  }
573
710
  else {
574
711
  // result.id is the same id LaneCall.id was given above — the exact
@@ -604,6 +741,30 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
604
741
  statusText: result.status === 'fulfilled' ? 'Approved.' : 'Error.',
605
742
  });
606
743
  }
744
+ // At least one call this batch is durably pending — resultBlocks is
745
+ // incomplete (missing an entry for every pending item) and must
746
+ // NOT be pushed as the completing tool_result message: a real model
747
+ // API expects exactly one, complete, tool_result per tool_use
748
+ // message, not a partial one now and a follow-up later. Stop here
749
+ // instead — newMessages already ends at the dangling assistant
750
+ // tool_use message pushed above, the same crash-recovery shape
751
+ // session-store.ts's own hasUnresolvedToolCall already detects (see
752
+ // RunAgentResult.stopReason's own doc comment). Turning this into a
753
+ // durable TurnCheckpoint is the caller's job — this loop does no I/O.
754
+ if (pending.length > 0) {
755
+ // A mixed batch (both kinds present) reports 'pending_approval' —
756
+ // see RunAgentResult.stopReason's own doc comment for why that's
757
+ // the right call, not an arbitrary tie-break.
758
+ const stopReason = pending.some((p) => p.kind === 'approval') ? 'pending_approval' : 'pending_question';
759
+ log({ type: stopReason === 'pending_question' ? 'loop:pending_question' : 'loop:pending_approval', pendingIds: pending.map((p) => p.pendingId) });
760
+ return {
761
+ text: `Stopped — awaiting durable ${stopReason === 'pending_question' ? 'answer' : 'approval'} for: ${pending.map((p) => p.tool).join(', ')}.`,
762
+ history: messages,
763
+ newMessages,
764
+ stopReason,
765
+ pending: { resultsSoFar: resultBlocks, outstanding: pending },
766
+ };
767
+ }
607
768
  }
608
769
  pushMessage({ role: 'user', content: resultBlocks });
609
770
  // Stop here, don't loop back for another model call — same reasoning
@@ -620,3 +781,101 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
620
781
  }
621
782
  }
622
783
  }
784
+ /** Invokes an AgentConfig.onRunStart/onRunFinish callback per its own
785
+ * never-awaited contract (see AgentConfig's own doc comments on each) —
786
+ * whether `call()` throws synchronously or returns a rejected Promise,
787
+ * the failure is logged, never surfaced to (or awaited by) the loop that
788
+ * triggered it. One shared implementation for both hooks and both of
789
+ * runAgent/resumeAgent's own call sites, rather than four copies of the
790
+ * same try/catch-and-maybe-.catch dance. */
791
+ function fireLifecycleHook(label, agentName, call) {
792
+ try {
793
+ const result = call();
794
+ if (result && typeof result.catch === 'function') {
795
+ result.catch((err) => console.error(`[loopengine] ${label} threw for agent '${agentName}':`, err));
796
+ }
797
+ }
798
+ catch (err) {
799
+ console.error(`[loopengine] ${label} threw for agent '${agentName}':`, err);
800
+ }
801
+ }
802
+ /** `cli` and `http_stream` both already deliver the start/finish signal
803
+ * to whoever's actually waiting, synchronously, as part of that channel's
804
+ * own normal response — a terminal that just ran the command, or an SSE
805
+ * connection that already carries its own `session`/`done` events on the
806
+ * exact same stream. Firing onRunStart/onRunFinish there too would just
807
+ * be a second, redundant copy of information the caller already has, so
808
+ * neither hook is invoked for those two channels — not left to each
809
+ * implementation to notice and filter out itself the way `channel` in
810
+ * the fired context still lets you filter *within* the channels this
811
+ * does fire for (`http`, or no channel at all — a bespoke script has no
812
+ * such guaranteed synchronous delivery to assume either way, so it still
813
+ * fires there). */
814
+ function shouldFireLifecycleHooks(channel) {
815
+ return channel !== 'cli' && channel !== 'http_stream';
816
+ }
817
+ export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
818
+ const ctx = await buildTurnContext(config, modelCall, options);
819
+ // config.onRunStart, if set, still wins outright over notifier's own
820
+ // derived hook — see NotifierConfig's own doc comment on precedence.
821
+ const onRunStart = config.onRunStart ?? (options.channel === 'http' ? ctx.httpNotifier.onRunStart : undefined);
822
+ if (onRunStart && shouldFireLifecycleHooks(options.channel)) {
823
+ fireLifecycleHook('onRunStart', config.name, () => onRunStart({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, trigger: 'message' }));
824
+ }
825
+ const result = await runLoop(ctx, [...history], [], { role: 'user', content: userMessage });
826
+ const onRunFinish = config.onRunFinish ?? (options.channel === 'http' ? ctx.httpNotifier.onRunFinish : undefined);
827
+ if (onRunFinish && !result.pending && shouldFireLifecycleHooks(options.channel)) {
828
+ fireLifecycleHook('onRunFinish', config.name, () => onRunFinish({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, text: result.text, stopReason: result.stopReason }));
829
+ }
830
+ return result;
831
+ }
832
+ /** Continues a turn durably paused on RunAgentResult.stopReason ===
833
+ * 'pending_approval' — see HUMAN_IN_THE_LOOP.md for the full design.
834
+ * `history` is durable session history including the dangling assistant
835
+ * tool_use message the pause left behind (exactly what a caller's
836
+ * SessionStore.getHistory/withSession already returns — this needs no
837
+ * special resumed-session handling, since that message is real, already
838
+ * on disk). `resolution` is the *complete* set of tool_result blocks
839
+ * answering that message — every entry from a resolved TurnCheckpoint's
840
+ * own resultsSoFar, once its outstanding is empty; assembling that is the
841
+ * caller's job (core/durable-approvals.ts's own CheckpointStore only
842
+ * stores it, it doesn't execute tools or know when a batch is complete —
843
+ * see that module's own doc comment), not something this function
844
+ * derives on its own.
845
+ *
846
+ * Pushes `resolution` as the completing message and picks the loop back
847
+ * up from there — a fresh model call, now with the full picture, exactly
848
+ * like a synchronous 'ask' would have continued if it hadn't needed to
849
+ * pause at all. Can itself return with stopReason 'pending_approval'
850
+ * again (the model's next round of tool calls hits another durably-gated
851
+ * one), 'denied', 'max_turns', or a genuine finish — runLoop handles all
852
+ * of those uniformly regardless of how this turn started. */
853
+ export async function resumeAgent(config, modelCall, history, resolution, options = {}) {
854
+ const ctx = await buildTurnContext(config, modelCall, options);
855
+ const onRunStart = config.onRunStart ?? (options.channel === 'http' ? ctx.httpNotifier.onRunStart : undefined);
856
+ if (onRunStart && shouldFireLifecycleHooks(options.channel)) {
857
+ fireLifecycleHook('onRunStart', config.name, () => onRunStart({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, trigger: 'resolution' }));
858
+ }
859
+ // sessions.withSession's own load (adapters/http.ts's respondAfterResolution)
860
+ // runs through the exact same hasUnresolvedToolCall check real crash
861
+ // recovery uses — a durable pause deliberately leaves the identical
862
+ // dangling-tool_use shape (see HUMAN_IN_THE_LOOP.md's own "same
863
+ // crash-recovery shape... entered on purpose"), so session-store.ts has
864
+ // no way to tell the two apart and injects its own synthetic
865
+ // CRASH_RECOVERY_CONTINUATION message regardless. Only resumeAgent
866
+ // itself knows, by the fact that it's being called at all with a real
867
+ // resolution in hand, that this wasn't a crash — so it's the only place
868
+ // that can safely strip that message back out before it ends up wedged
869
+ // between the assistant's tool_calls message and the tool_result
870
+ // answering it, which every OpenAI-wire-compatible provider rejects
871
+ // outright (an assistant message with tool_calls must be immediately
872
+ // followed by one tool message per call, nothing in between).
873
+ const lastMessage = history[history.length - 1];
874
+ const effectiveHistory = lastMessage?.role === 'user' && lastMessage.content === CRASH_RECOVERY_CONTINUATION ? history.slice(0, -1) : history;
875
+ const result = await runLoop(ctx, [...effectiveHistory], [], { role: 'user', content: resolution });
876
+ const onRunFinish = config.onRunFinish ?? (options.channel === 'http' ? ctx.httpNotifier.onRunFinish : undefined);
877
+ if (onRunFinish && !result.pending && shouldFireLifecycleHooks(options.channel)) {
878
+ fireLifecycleHook('onRunFinish', config.name, () => onRunFinish({ agent: config.name, tenant: ctx.scope.tenant, sessionId: options.sessionId, channel: options.channel, text: result.text, stopReason: result.stopReason }));
879
+ }
880
+ return result;
881
+ }
@@ -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. */
@@ -75,11 +75,13 @@ function hasUnresolvedToolCall(message) {
75
75
  Array.isArray(message.content) &&
76
76
  message.content.some((block) => block.type === 'tool_use'));
77
77
  }
78
+ // Exported so resumeAgent (run-agent.ts) can strip this exact message
79
+ // back out when it turns out the "interruption" wasn't a crash at all —
80
+ // see that constant's own doc comment for why the two can't be told
81
+ // apart at the point sessionknit's own resume() runs.
82
+ export const CRASH_RECOVERY_CONTINUATION = '[session resumed after an interruption — the assistant had requested tool calls but the process stopped before any results were recorded; treat those tools as not having run]';
78
83
  function buildContinuation() {
79
- return {
80
- role: 'user',
81
- content: '[session resumed after an interruption — the assistant had requested tool calls but the process stopped before any results were recorded; treat those tools as not having run]',
82
- };
84
+ return { role: 'user', content: CRASH_RECOVERY_CONTINUATION };
83
85
  }
84
86
  /** Wraps a SessionKnit log behind the flat load/run/append contract
85
87
  * runAgent's callers use. Shared by both the file- and Redis-backed
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: composio-large-outputs
3
+ description: How to retrieve a gateway tool's real output when its result says storedInFile is true instead of returning the data inline.
4
+ ---
5
+
6
+ # Gateway tools with a large result
7
+
8
+ Some gateway tools (Composio-sourced ones in particular) return a small
9
+ pointer object instead of their real output when the actual result is
10
+ too large to return inline:
11
+
12
+ ```
13
+ {
14
+ "successful": true,
15
+ "storedInFile": true,
16
+ "tokenCount": 10234,
17
+ "outputFilePath": "/tmp/.../SOME_TOOL_OUTPUT_xxxxx.json"
18
+ }
19
+ ```
20
+
21
+ When you see `storedInFile: true` in a tool result, the real data is
22
+ **not** in that object — it's in the file at `outputFilePath`. Call
23
+ `system_read_file` with that exact path to retrieve the actual content
24
+ before answering. Don't guess at an answer, summarize from the pointer
25
+ alone, or claim you don't have the data — read the file first.