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.
- package/PROTOCOL.md +3 -3
- package/README.md +45 -10
- package/dist/core/agent-config.d.ts +291 -8
- package/dist/core/budget.d.ts +42 -0
- package/dist/core/budget.js +39 -0
- package/dist/core/compaction.d.ts +43 -0
- package/dist/core/compaction.js +70 -0
- package/dist/core/http-notifier.d.ts +10 -0
- package/dist/core/http-notifier.js +116 -0
- package/dist/core/http-notify-triggers/database.d.ts +19 -0
- package/dist/core/http-notify-triggers/database.js +53 -0
- package/dist/core/http-notify-triggers/email.d.ts +44 -0
- package/dist/core/http-notify-triggers/email.js +126 -0
- package/dist/core/http-notify-triggers/lark.d.ts +28 -0
- package/dist/core/http-notify-triggers/lark.js +168 -0
- package/dist/core/http-notify-triggers/redis.d.ts +33 -0
- package/dist/core/http-notify-triggers/redis.js +54 -0
- package/dist/core/http-notify-triggers/slack.d.ts +21 -0
- package/dist/core/http-notify-triggers/slack.js +91 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
- package/dist/core/http-notify-triggers/webhook.js +94 -0
- package/dist/core/loop-events.d.ts +30 -6
- package/dist/core/model-calls/openai-model-call.js +32 -16
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +141 -13
- package/dist/core/run-agent.js +316 -57
- package/dist/core/session-store.d.ts +1 -0
- package/dist/core/session-store.js +6 -4
- package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/core/system-tools/ask_user.d.ts +97 -31
- package/dist/core/system-tools/ask_user.js +157 -58
- package/dist/core/system-tools/index.d.ts +1 -1
- package/dist/core/system-tools/index.js +1 -1
- package/dist/package.json +6 -0
- package/package.json +14 -4
- package/protocol/loop-event.schema.json +2 -2
- /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
package/dist/core/run-agent.js
CHANGED
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|