loopengine 0.0.18 → 0.0.21
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 +245 -0
- package/README.md +44 -5
- package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
- package/dist/{cli.js → bin/cli.js} +131 -16
- package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
- package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
- package/dist/core/client.d.ts +113 -0
- package/dist/core/client.js +123 -0
- package/dist/core/discover-agents.d.ts +45 -0
- package/dist/{discover-agents.js → core/discover-agents.js} +11 -3
- package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
- package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
- package/dist/core/loop-events.d.ts +175 -0
- package/dist/core/loop-events.js +1 -0
- package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/openai-model-call.js +1 -1
- package/dist/{run-agent.d.ts → core/run-agent.d.ts} +79 -11
- package/dist/{run-agent.js → core/run-agent.js} +242 -60
- package/dist/{session-store.d.ts → core/session-store.d.ts} +14 -2
- package/dist/{session-store.js → core/session-store.js} +19 -0
- package/dist/core/system-tools/ask_user.d.ts +72 -0
- package/dist/core/system-tools/ask_user.js +136 -0
- package/dist/core/system-tools/index.d.ts +3 -0
- package/dist/core/system-tools/index.js +12 -0
- package/dist/core/system-tools/read_file.d.ts +28 -0
- package/dist/core/system-tools/read_file.js +64 -0
- package/dist/{vector-index.js → core/vector-index.js} +1 -1
- package/dist/index.d.ts +19 -16
- package/dist/index.js +16 -9
- package/package.json +34 -28
- package/protocol/loop-event.schema.json +257 -0
- package/dist/discover-agents.d.ts +0 -24
- /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
- /package/dist/{agent-config.js → core/agent-config.js} +0 -0
- /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
- /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
|
@@ -9,29 +9,51 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
9
9
|
import { parse as parseYaml } from 'yaml';
|
|
10
10
|
import { Gate, RuleSet, ConsoleApprover } from 'actauth';
|
|
11
11
|
import { SkillGarden } from 'skillgarden';
|
|
12
|
-
import {
|
|
12
|
+
import { BudgetTracker } from './budget.js';
|
|
13
|
+
import { Compactor } from './compaction.js';
|
|
13
14
|
import { ToolLane } from 'toollane';
|
|
14
|
-
import {
|
|
15
|
+
import { Recovery } from './recovery.js';
|
|
15
16
|
import { loadAgentModule } from './discover-agents.js';
|
|
16
17
|
import { agentAsTool } from './agent-as-tool.js';
|
|
17
18
|
import { loadGatewayToolsFromDir } from './gateway-tools.js';
|
|
19
|
+
import { systemTools, createAskUserTool } from './system-tools/index.js';
|
|
20
|
+
export { systemTools } from './system-tools/index.js';
|
|
18
21
|
// Resolved relative to *this file's own location* (via import.meta.url),
|
|
19
|
-
// not process.cwd() — the same reasoning agent-registry.ts's own
|
|
20
|
-
// agentsDir uses. In dev (tsx), this file
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
// not process.cwd() — the same reasoning core/agent-registry.ts's own
|
|
23
|
+
// agentsDir uses. In dev (tsx), this file lives in core/ with agents/ one
|
|
24
|
+
// level up, at repo root. In the built dist/, this file is
|
|
25
|
+
// dist/core/run-agent.js with dist/agents/**/*.js (tsc-compiled,
|
|
26
|
+
// preserving the source tree's shape) one level up from *it* the same
|
|
27
|
+
// way — cwd is irrelevant to either case, only "one level up from this
|
|
28
|
+
// module" is reliably right in both. skillsDirs/rules don't need this:
|
|
29
|
+
// actauth.yml/SKILL.md are plain data copied verbatim into the image (see
|
|
30
|
+
// Dockerfile), so they exist at the same cwd-relative path either way; a
|
|
31
|
+
// tools/index.ts is real code that only exists compiled, at a different
|
|
32
|
+
// relative location, once dist/ is what's actually running.
|
|
33
|
+
const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
|
|
34
|
+
// Same "resolved relative to this file's own location" reasoning as
|
|
35
|
+
// agentsRootDir above, but system-skills/ is a same-level sibling of
|
|
36
|
+
// run-agent.ts itself (both live directly under core/ — see package.json's
|
|
37
|
+
// own "files" list), not one level up the way agents/ is, so this one
|
|
38
|
+
// stays a direct join with no '..'.
|
|
39
|
+
export const systemSkillsDir = join(dirname(fileURLToPath(import.meta.url)), 'system-skills');
|
|
40
|
+
// Tools every agent gets (see systemTools' own doc comment) are merged
|
|
41
|
+
// in *first*, not last — combined with keeping each name's *last*
|
|
42
|
+
// occurrence (a plain Map naturally does this: re-setting an existing
|
|
43
|
+
// key overwrites its value without moving it), that means an agent's
|
|
44
|
+
// own tool of the same name (config.tools, a subagent, a gateway source)
|
|
45
|
+
// always wins over the system default, never the reverse. Also used to
|
|
46
|
+
// dedupe the *array itself*, not just resolution at call time — without
|
|
47
|
+
// this, a name collision would still show up twice in toolSchemas,
|
|
48
|
+
// confusing (or rejected outright by) a real model API.
|
|
49
|
+
export function dedupeToolsByName(list) {
|
|
50
|
+
return [...new Map(list.map((t) => [t.name, t])).values()];
|
|
51
|
+
}
|
|
52
|
+
/** budget.ts/compaction.ts only ever need a flat string per message to
|
|
53
|
+
* estimate token usage — neither needs to understand tool_use/tool_result
|
|
32
54
|
* structure, so this is a one-way, read-only projection, never something
|
|
33
55
|
* that needs to be un-projected back. */
|
|
34
|
-
function
|
|
56
|
+
function flattenForBudget(message) {
|
|
35
57
|
if (typeof message.content === 'string')
|
|
36
58
|
return { role: message.role, content: message.content };
|
|
37
59
|
const text = message.content
|
|
@@ -241,28 +263,49 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
241
263
|
// gateway-tools.ts) are both merged in on top either way — see
|
|
242
264
|
// loadSubagentAsTools's own doc comment for why neither is gated by
|
|
243
265
|
// whether `tools` was explicit.
|
|
244
|
-
|
|
266
|
+
// systemTools go first and are deduped by name (dedupeToolsByName keeps
|
|
267
|
+
// each name's *last* occurrence) — so config.tools/subagents/gateway
|
|
268
|
+
// tools always win over a same-named system default, never the
|
|
269
|
+
// reverse, even though systemTools is merged in unconditionally
|
|
270
|
+
// (unlike the others below, it's not gated on `tools` being omitted).
|
|
271
|
+
// ask_user is built fresh per call, not part of the static systemTools
|
|
272
|
+
// array — its onPending needs options.onQuestionPending, not `log`
|
|
273
|
+
// (options.onEvent's own fallback) — see RunAgentOptions.onQuestionPending's
|
|
274
|
+
// own doc comment for why those two are deliberately not the same thing.
|
|
275
|
+
const askUserTool = createAskUserTool({ agent: config.name, sessionId: options.sessionId }, options.onQuestionPending);
|
|
276
|
+
const tools = dedupeToolsByName([
|
|
277
|
+
...systemTools,
|
|
278
|
+
askUserTool,
|
|
245
279
|
...(config.tools ?? (await loadDefaultTools(config))),
|
|
246
280
|
...(await loadSubagentAsTools(config)),
|
|
247
281
|
...(await loadGatewayToolsFromDir(join(agentsRootDir, config.name))),
|
|
248
|
-
];
|
|
282
|
+
]);
|
|
249
283
|
// Omitted entirely (undefined): default to this agent's own
|
|
250
284
|
// agents/<name>/skills — the folder-form convention every agent in
|
|
251
285
|
// this repo already follows (see agent-config.ts's skillsDirs doc
|
|
252
|
-
// comment). An explicit `[]` is a real opt-out
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
286
|
+
// comment). An explicit `[]` is a real opt-out of the agent's *own*
|
|
287
|
+
// skills dir, but systemSkillsDir is still always included below —
|
|
288
|
+
// same "not opt-out-able the normal way" as systemTools above.
|
|
289
|
+
// SkillGarden's own missing-directory handling (discoverSkillFiles
|
|
290
|
+
// walks best-effort, no throw) is what makes pointing this at a folder
|
|
291
|
+
// that doesn't exist (a flat-file agent with no skills at all)
|
|
292
|
+
// harmless: an empty index, not an error.
|
|
257
293
|
const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
|
|
258
|
-
const skillGarden =
|
|
259
|
-
const skillIndex = skillGarden
|
|
294
|
+
const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 });
|
|
295
|
+
const skillIndex = skillGarden.buildIndex().included;
|
|
260
296
|
// Also the tail-preservation window recover() below relies on — kept as
|
|
261
297
|
// one named constant so the two can never drift out of sync.
|
|
262
298
|
const tailMessages = 4;
|
|
263
|
-
|
|
299
|
+
// Two separate objects, not one — budget.ts's BudgetTracker (read-only
|
|
300
|
+
// check) and compaction.ts's Compactor (the actual recovery) are
|
|
301
|
+
// deliberately distinct capabilities now, constructed with the same
|
|
302
|
+
// budgetTokens/softThreshold so a nudge firing and compaction's own
|
|
303
|
+
// recovery target agree — see budget.ts's own BudgetTrackerOptions doc
|
|
304
|
+
// comment.
|
|
305
|
+
const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
|
|
306
|
+
const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
|
|
264
307
|
const rules = loadRules(config);
|
|
265
|
-
const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
|
|
308
|
+
const gate = new Gate(rules, config.approver ?? options.approver ?? new ConsoleApprover());
|
|
266
309
|
// No explicit isSafeTool: fall back to each called tool's own `safe`
|
|
267
310
|
// flag (looked up by name) rather than defaulting every tool to unsafe
|
|
268
311
|
// outright — see ToolDefinition.safe's own doc comment for why this is
|
|
@@ -280,21 +323,21 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
280
323
|
messages.push(message);
|
|
281
324
|
newMessages.push(message);
|
|
282
325
|
}
|
|
283
|
-
const
|
|
326
|
+
const recovery = new Recovery({
|
|
284
327
|
onPromptTooLong: async (currentMessages) => {
|
|
285
328
|
// newMessages (this turn's own content — not durably stored
|
|
286
|
-
// anywhere else yet) must never be handed to
|
|
329
|
+
// anywhere else yet) must never be handed to the compactor at all.
|
|
287
330
|
// An earlier version of this reconciliation tried to recover
|
|
288
|
-
// newMessages *after* compaction by reusing whatever
|
|
289
|
-
// synthetic head contained — that's unsound:
|
|
331
|
+
// newMessages *after* compaction by reusing whatever the
|
|
332
|
+
// compactor's synthetic head contained — that's unsound: its cheap
|
|
290
333
|
// "drain" stage doesn't produce a head at all, it just deletes old
|
|
291
334
|
// messages outright, trusting there's a durable copy elsewhere.
|
|
292
335
|
// That trust is only valid for the *prior* portion; only that
|
|
293
336
|
// portion is safe to compact at all.
|
|
294
337
|
const priorCount = currentMessages.length - newMessages.length;
|
|
295
338
|
const priorPortion = currentMessages.slice(0, priorCount);
|
|
296
|
-
const result = await
|
|
297
|
-
log('
|
|
339
|
+
const result = await compactor.recover(priorPortion.map(flattenForBudget));
|
|
340
|
+
log({ type: 'prompt:compaction', from: currentMessages.length, to: result.messages.length + newMessages.length });
|
|
298
341
|
if (result.action === 'unchanged')
|
|
299
342
|
return currentMessages;
|
|
300
343
|
// Same tail-preservation reuse as before, just scoped to
|
|
@@ -304,13 +347,13 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
304
347
|
const structuredTail = priorPortion.slice(priorPortion.length - preservedTailCount);
|
|
305
348
|
const newHead = result.messages.slice(0, result.messages.length - preservedTailCount);
|
|
306
349
|
const recovered = [...newHead, ...structuredTail, ...newMessages];
|
|
307
|
-
//
|
|
350
|
+
// Recovery.call() only ever returns {value, recoveries, truncated} —
|
|
308
351
|
// it never hands back whatever `currentMessages` became after a
|
|
309
352
|
// retry, so recovery would otherwise only ever affect the one
|
|
310
353
|
// retried call. Reassigning the outer `messages` binding here (this
|
|
311
354
|
// hook is the only place that has both the recovered array and a
|
|
312
355
|
// reason to persist it) is what makes recovery durable: every
|
|
313
|
-
// subsequent
|
|
356
|
+
// subsequent budgetTracker.check() in this loop, and the `history`
|
|
314
357
|
// this function eventually returns, both see the compacted
|
|
315
358
|
// conversation from this point on — not the original, ever-growing
|
|
316
359
|
// one. newMessages itself needs no reconciliation at all — it was
|
|
@@ -320,6 +363,19 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
320
363
|
},
|
|
321
364
|
});
|
|
322
365
|
const toolsByName = new Map(tools.map((t) => [t.name, t]));
|
|
366
|
+
// Object identity, not name — an agent that overrides a system tool's
|
|
367
|
+
// name with its own ToolDefinition (dedupeToolsByName's own "config
|
|
368
|
+
// wins" rule above) is a deliberate opt-out, and that override should
|
|
369
|
+
// still go through the agent's own rules like anything else; only the
|
|
370
|
+
// genuine, unmodified system implementation always bypasses the gate.
|
|
371
|
+
// ask_user in particular can't be gated at all without a real deadlock:
|
|
372
|
+
// it's the mechanism a human uses to answer the agent, so requiring a
|
|
373
|
+
// human decision just to *ask* one is circular, not just redundant —
|
|
374
|
+
// and every default_decision this repo's own agents actually use
|
|
375
|
+
// ('ask' or 'deny', see loadRules' own doc comment) would otherwise
|
|
376
|
+
// catch it, since neither system tool ever appears in an agent's own
|
|
377
|
+
// actauth.yml.
|
|
378
|
+
const systemToolInstances = new Set([...systemTools, askUserTool]);
|
|
323
379
|
const toolSchemas = tools.map(({ name, description, input_schema }) => ({
|
|
324
380
|
name,
|
|
325
381
|
description,
|
|
@@ -362,16 +418,21 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
362
418
|
if (turn > maxTurns) {
|
|
363
419
|
const text = `Stopped after ${maxTurns} turns without a final answer — the agent may be stuck in a loop.`;
|
|
364
420
|
pushMessage({ role: 'assistant', content: text });
|
|
365
|
-
log('loop:max_turns',
|
|
421
|
+
log({ type: 'loop:max_turns', maxTurns });
|
|
366
422
|
return { text, history: messages, newMessages, stopReason: 'max_turns' };
|
|
367
423
|
}
|
|
368
|
-
const budget =
|
|
369
|
-
log('
|
|
424
|
+
const budget = budgetTracker.check(messages.map(flattenForBudget));
|
|
425
|
+
log({ type: 'budget:check', ...budget });
|
|
370
426
|
if (budget.action === 'nudge' && budget.nudge)
|
|
371
427
|
pushMessage(budget.nudge);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
428
|
+
// recovery.call()'s own return also includes `recoveries` (which of
|
|
429
|
+
// its three failure modes fired this call) and `truncated` — not
|
|
430
|
+
// used here: prompt:compaction above already reports the one
|
|
431
|
+
// recovery type that's actually wired to a real hook (onPromptTooLong),
|
|
432
|
+
// and there's no second type yet to justify a second, more generic
|
|
433
|
+
// event just to aggregate it (see PromptCompactionEvent's own doc
|
|
434
|
+
// comment for the removed 'recovery:summary').
|
|
435
|
+
const { value: response } = await recovery.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
|
|
375
436
|
// The model's full response — text and tool_use blocks alike, with
|
|
376
437
|
// real ids — becomes this turn's assistant message verbatim. Every
|
|
377
438
|
// tool_use block emitted here gets a matching tool_result pushed
|
|
@@ -381,11 +442,34 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
381
442
|
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
|
382
443
|
if (toolUseBlocks.length === 0) {
|
|
383
444
|
const text = response.content.find((b) => b.type === 'text')?.text ?? '';
|
|
384
|
-
log('loop:done', text);
|
|
445
|
+
log({ type: 'loop:done', text });
|
|
385
446
|
return { text, history: messages, newMessages };
|
|
386
447
|
}
|
|
448
|
+
// The model's own text alongside a tool_use request (its "I'll do X"
|
|
449
|
+
// preamble) is already durable via pushMessage above, but the only
|
|
450
|
+
// *live* SSE event that ever carries assistant text is 'done', which
|
|
451
|
+
// fires once at the very end of the whole turn — a live caller
|
|
452
|
+
// watching this turn in progress (adapters/http.ts's streaming route,
|
|
453
|
+
// the playground) would otherwise jump straight from "thinking..." to
|
|
454
|
+
// an approval/question card with zero explanation, and only ever see
|
|
455
|
+
// this sentence later, after a refresh replays stored history —
|
|
456
|
+
// confirmed live.
|
|
457
|
+
const preambleText = response.content.find((b) => b.type === 'text')?.text;
|
|
458
|
+
if (preambleText)
|
|
459
|
+
log({ type: 'assistant:text', text: preambleText });
|
|
387
460
|
const resultBlocks = [];
|
|
388
461
|
const approvedCalls = [];
|
|
462
|
+
const deniedTools = [];
|
|
463
|
+
// Keyed by tool_use id — approvedCalls (a toollane LaneCall) has no
|
|
464
|
+
// room for extra fields of its own, and toolLane.run's own results
|
|
465
|
+
// only carry `id`/`name`/status, not anything about the *decision*
|
|
466
|
+
// that let a call through in the first place (or what it was even
|
|
467
|
+
// called with). This is what lets the reason still get attached to
|
|
468
|
+
// the right tool_result once execution finishes below, and lets the
|
|
469
|
+
// 'tool:result' live event (see below) report the same args a
|
|
470
|
+
// resumed/refreshed view of this same call would show, without
|
|
471
|
+
// changing toollane's own types.
|
|
472
|
+
const approvedMetaById = new Map();
|
|
389
473
|
for (const block of toolUseBlocks) {
|
|
390
474
|
// Skill invocation injects instructions into context; it isn't a
|
|
391
475
|
// real tool call and doesn't need ActAuth gating or ToolLane
|
|
@@ -393,13 +477,43 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
393
477
|
// so it still needs a tool_result to answer it, same as any other.
|
|
394
478
|
if (block.name === 'Skill' && skillGarden) {
|
|
395
479
|
const body = skillGarden.invoke(block.input.skill, block.input?.args);
|
|
396
|
-
log('
|
|
480
|
+
log({ type: 'skill:loaded', skill: block.input.skill });
|
|
397
481
|
resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
|
|
398
482
|
continue;
|
|
399
483
|
}
|
|
484
|
+
// See systemToolInstances' own doc comment above — bypasses
|
|
485
|
+
// gate.evaluate entirely, not just auto-approves through it, since
|
|
486
|
+
// ask_user itself can be the approver's own only way to ask a human
|
|
487
|
+
// anything in the first place.
|
|
488
|
+
if (systemToolInstances.has(toolsByName.get(block.name))) {
|
|
489
|
+
log({ type: 'actauth:decision', tool: block.name, decision: 'allow', reason: 'system tool — always allowed' });
|
|
490
|
+
approvedMetaById.set(block.id, { reason: 'system tool — always allowed', args: block.input ?? {} });
|
|
491
|
+
// Fired the moment the decision is in, not once execution finishes
|
|
492
|
+
// (see the 'tool:started' vs 'tool:result' split below) — a system
|
|
493
|
+
// tool can still be slow, and this is the one path that had no
|
|
494
|
+
// interactive card of its own to show something sooner.
|
|
495
|
+
log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: 'system tool — always allowed' });
|
|
496
|
+
approvedCalls.push({
|
|
497
|
+
id: block.id,
|
|
498
|
+
name: block.name,
|
|
499
|
+
execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
|
|
500
|
+
});
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
400
503
|
const decision = await gate.evaluate(block.name, block.input ?? {}, scope);
|
|
401
|
-
log('actauth:decision',
|
|
504
|
+
log({ type: 'actauth:decision', tool: block.name, decision: decision.decision, reason: decision.reason });
|
|
402
505
|
if (decision.decision === 'allow') {
|
|
506
|
+
approvedMetaById.set(block.id, { reason: decision.reason, args: block.input ?? {} });
|
|
507
|
+
// Fired the instant the decision is in, not once execution
|
|
508
|
+
// finishes below — closes what would otherwise be a multi-second
|
|
509
|
+
// silent gap while a slow call runs. Always emitted, whether or
|
|
510
|
+
// not a human was actually asked live: run-agent.ts itself has no
|
|
511
|
+
// notion of "already shown elsewhere" — that's a rendering
|
|
512
|
+
// decision, and belongs to whichever channel adapter is actually
|
|
513
|
+
// presenting this turn (see web/playground.ts's own
|
|
514
|
+
// wasAskedInteractively, which decides whether this duplicates an
|
|
515
|
+
// approval card already on screen), not to the engine loop.
|
|
516
|
+
log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason });
|
|
403
517
|
approvedCalls.push({
|
|
404
518
|
id: block.id,
|
|
405
519
|
name: block.name,
|
|
@@ -410,31 +524,99 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
410
524
|
// Every requested tool gets exactly one tool_result back — a
|
|
411
525
|
// denied call is not simply dropped, or the model has no way to
|
|
412
526
|
// tell "denied" apart from "hasn't run yet" and may just
|
|
413
|
-
// re-request it forever.
|
|
527
|
+
// re-request it forever. Recorded even though this turn is about
|
|
528
|
+
// to stop (see deniedTools below) — a later message continuing
|
|
529
|
+
// this same session still needs a complete, consistent history,
|
|
530
|
+
// not a dangling tool_use with no answer.
|
|
531
|
+
deniedTools.push(block.name);
|
|
414
532
|
resultBlocks.push({
|
|
415
533
|
type: 'tool_result',
|
|
416
534
|
tool_use_id: block.id,
|
|
417
535
|
content: `denied: ${decision.reason}`,
|
|
418
536
|
is_error: true,
|
|
419
537
|
});
|
|
538
|
+
// Always emitted — see the 'allow' branch's own comment above on
|
|
539
|
+
// why this engine loop no longer decides whether a live UI
|
|
540
|
+
// already has some other representation of this decision on
|
|
541
|
+
// screen (an approval card that went through an interactive
|
|
542
|
+
// ask). That's a rendering call, made by whichever channel
|
|
543
|
+
// adapter is presenting this turn.
|
|
544
|
+
log({ type: 'tool:result', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason, statusText: 'Denied.' });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (deniedTools.length > 0) {
|
|
548
|
+
// A denial cancels the *whole* batch, not just the call(s) that
|
|
549
|
+
// were themselves denied — an approved sibling call from the same
|
|
550
|
+
// turn never runs at all, same reasoning Claude Code itself stops
|
|
551
|
+
// an entire pending batch rather than quietly carrying out the
|
|
552
|
+
// parts you didn't object to. Every approved call still gets
|
|
553
|
+
// exactly one tool_result back regardless (the model emitted a
|
|
554
|
+
// tool_use for it, so it needs an answer) — "skipped", not
|
|
555
|
+
// "denied": it was never evaluated against its own rule, a
|
|
556
|
+
// *different* call in the same turn was what stopped it.
|
|
557
|
+
for (const call of approvedCalls) {
|
|
558
|
+
log({ type: 'loop:skipped', name: call.name, deniedTools });
|
|
559
|
+
const skipReason = `a sibling tool call in this turn (${deniedTools.join(', ')}) was denied`;
|
|
560
|
+
resultBlocks.push({
|
|
561
|
+
type: 'tool_result',
|
|
562
|
+
tool_use_id: call.id,
|
|
563
|
+
content: `skipped: ${skipReason}`,
|
|
564
|
+
is_error: true,
|
|
565
|
+
});
|
|
566
|
+
// A skipped call never went through gate.evaluate at all — no
|
|
567
|
+
// approver, no interactive card, nothing live ever showed it was
|
|
568
|
+
// even requested. Same reasoning as the straight-denied case
|
|
569
|
+
// just above.
|
|
570
|
+
log({ type: 'tool:result', id: call.id, tool: call.name, args: approvedMetaById.get(call.id)?.args, detailText: skipReason, statusText: 'Skipped.' });
|
|
420
571
|
}
|
|
421
572
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
type: '
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
573
|
+
else {
|
|
574
|
+
// result.id is the same id LaneCall.id was given above — the exact
|
|
575
|
+
// per-call identity that makes it possible to link a result back
|
|
576
|
+
// to the specific tool_use block that requested it, even when
|
|
577
|
+
// several calls ran in the same parallel lane. The old
|
|
578
|
+
// flattened-text design could only link by tool *name*, ambiguous
|
|
579
|
+
// the moment two calls to the same tool ran in one turn.
|
|
580
|
+
for await (const result of toolLane.run(approvedCalls)) {
|
|
581
|
+
const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
|
|
582
|
+
const meta = approvedMetaById.get(result.id);
|
|
583
|
+
log({ type: 'toollane:result', name: result.name, summary });
|
|
584
|
+
resultBlocks.push({
|
|
585
|
+
type: 'tool_result',
|
|
586
|
+
tool_use_id: result.id,
|
|
587
|
+
content: summary,
|
|
588
|
+
is_error: result.status === 'rejected',
|
|
589
|
+
reason: meta?.reason,
|
|
590
|
+
});
|
|
591
|
+
// Always emitted — this is what turns the earlier 'tool:started'
|
|
592
|
+
// event's "Running…" placeholder into the real outcome (matched
|
|
593
|
+
// by the same id), for every call regardless of how it got
|
|
594
|
+
// approved. See the 'allow' branch's own comment above: deciding
|
|
595
|
+
// whether this duplicates an approval card already on screen is
|
|
596
|
+
// a rendering call for the channel adapter presenting this turn,
|
|
597
|
+
// not something this engine loop tracks.
|
|
598
|
+
log({
|
|
599
|
+
type: 'tool:result',
|
|
600
|
+
id: result.id,
|
|
601
|
+
tool: result.name,
|
|
602
|
+
args: meta?.args,
|
|
603
|
+
detailText: meta?.reason,
|
|
604
|
+
statusText: result.status === 'fulfilled' ? 'Approved.' : 'Error.',
|
|
605
|
+
});
|
|
606
|
+
}
|
|
437
607
|
}
|
|
438
608
|
pushMessage({ role: 'user', content: resultBlocks });
|
|
609
|
+
// Stop here, don't loop back for another model call — same reasoning
|
|
610
|
+
// Claude Code itself stops and waits for you rather than working
|
|
611
|
+
// around a tool call you just rejected, instead of silently handing
|
|
612
|
+
// "denied: ..." to the model and letting it decide on its own what to
|
|
613
|
+
// try next (retry the same tool, reach for a different one, or just
|
|
614
|
+
// talk its way past the refusal).
|
|
615
|
+
if (deniedTools.length > 0) {
|
|
616
|
+
const text = `Stopped — you denied: ${deniedTools.join(', ')}. Send another message to continue.`;
|
|
617
|
+
pushMessage({ role: 'assistant', content: text });
|
|
618
|
+
log({ type: 'loop:denied', deniedTools });
|
|
619
|
+
return { text, history: messages, newMessages, stopReason: 'denied' };
|
|
620
|
+
}
|
|
439
621
|
}
|
|
440
622
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
2
|
import { type Storage } from 'sessionknit';
|
|
3
|
-
import type { Message } from '#run-agent.js';
|
|
3
|
+
import type { Message } from '#core/run-agent.js';
|
|
4
4
|
export interface SessionResult<T> {
|
|
5
5
|
/** Exactly what this turn added — durably appended as-is, in order.
|
|
6
6
|
* This has to come from the caller explicitly (runAgent's own
|
|
7
7
|
* RunAgentResult.newMessages) rather than being inferred by diffing a
|
|
8
8
|
* returned `history` against what withSession loaded: that only works
|
|
9
|
-
* if history only ever grows, which isn't true once
|
|
9
|
+
* if history only ever grows, which isn't true once compaction.ts's own
|
|
10
10
|
* recovery can shrink/rewrite it mid-turn — see RunAgentResult's own
|
|
11
11
|
* doc comment for the full reasoning. */
|
|
12
12
|
newMessages: Message[];
|
|
@@ -18,6 +18,17 @@ export interface SessionStore {
|
|
|
18
18
|
* process or (for RedisSessionStore) any other — durably appends
|
|
19
19
|
* fn's result.newMessages in order, and resolves with fn's result. */
|
|
20
20
|
withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
|
|
21
|
+
/** Read-only peek at a session's current history — no new turn, nothing
|
|
22
|
+
* appended. Backs adapters/http.ts's GET .../sessions/:id (the
|
|
23
|
+
* playground's "resume a past conversation" sidebar rehydrates the chat
|
|
24
|
+
* pane from this, rather than pretending a new message was sent).
|
|
25
|
+
* Deliberately *not* behind withSession's own per-sessionId exclusivity
|
|
26
|
+
* — see the concrete implementation's own doc comment for why that's
|
|
27
|
+
* safe, and why this needs to work *especially* while a turn is still
|
|
28
|
+
* in flight (blocked on a human — see web/web-approver.ts /
|
|
29
|
+
* system-tools/ask_user.ts),
|
|
30
|
+
* not only once it's finished. */
|
|
31
|
+
getHistory(sessionId: string): Promise<Message[]>;
|
|
21
32
|
close(): Promise<void>;
|
|
22
33
|
}
|
|
23
34
|
/** Serializes async work per key within this process. Good enough for
|
|
@@ -40,6 +51,7 @@ declare class SessionKnitStore implements SessionStore {
|
|
|
40
51
|
private readonly knit;
|
|
41
52
|
constructor(storage: Storage<Message>, lock: <T>(sessionId: string, fn: () => Promise<T>) => Promise<T>, onClose?: () => Promise<void>);
|
|
42
53
|
withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
|
|
54
|
+
getHistory(sessionId: string): Promise<Message[]>;
|
|
43
55
|
close(): Promise<void>;
|
|
44
56
|
}
|
|
45
57
|
/** One JSONL entry log per session under `dir`. Fine for a single local
|
|
@@ -107,6 +107,25 @@ class SessionKnitStore {
|
|
|
107
107
|
return result;
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
|
+
// Deliberately *not* behind this.lock the way withSession is — that
|
|
111
|
+
// lock is held for withSession's *entire* fn(), which can now mean an
|
|
112
|
+
// 'ask' decision or an ask_user question blocked on a human for
|
|
113
|
+
// minutes (see web/web-approver.ts / system-tools/ask_user.ts). Confirmed
|
|
114
|
+
// live: a session with a pending question hung this call indefinitely,
|
|
115
|
+
// which is exactly backwards for what it's for — the playground's own
|
|
116
|
+
// "resume a session" flow (see its own checkForPendingItems) needs this
|
|
117
|
+
// to work *especially* while something's pending, not only once it
|
|
118
|
+
// isn't. Safe unlocked: withSession's own append loop only ever runs
|
|
119
|
+
// after fn() has already fully resolved (see its own body — nothing
|
|
120
|
+
// about the *next* turn is written until then), so there's nothing for
|
|
121
|
+
// an in-flight turn to race this against except that append loop
|
|
122
|
+
// itself, a few awaits, not the human-wait duration in front of it. A
|
|
123
|
+
// read that lands mid-append just sees an older, still-fully-valid
|
|
124
|
+
// prefix, not torn data.
|
|
125
|
+
async getHistory(sessionId) {
|
|
126
|
+
const { messages } = await this.knit.resume(sessionId);
|
|
127
|
+
return messages;
|
|
128
|
+
}
|
|
110
129
|
async close() {
|
|
111
130
|
await this.onClose();
|
|
112
131
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../agent-config.js';
|
|
2
|
+
export interface PendingQuestion {
|
|
3
|
+
id: string;
|
|
4
|
+
question: string;
|
|
5
|
+
/** Suggested answers, if the model gave any — never the only allowed
|
|
6
|
+
* answer, a human can still type free text either way (see
|
|
7
|
+
* answerQuestion, which takes any string). */
|
|
8
|
+
options?: string[];
|
|
9
|
+
/** Which agent raised this — always known (config.name), so listing can
|
|
10
|
+
* always at least be scoped per-agent even without a session id. */
|
|
11
|
+
agent: string;
|
|
12
|
+
/** Which conversation raised this, if the caller has one (see
|
|
13
|
+
* RunAgentOptions.sessionId's own doc comment for when it doesn't) —
|
|
14
|
+
* lets listQuestions scope down to exactly one conversation instead of
|
|
15
|
+
* every question raised by this agent, or this whole process. */
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
requestedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/** Builds the ask_user ToolDefinition for one runAgent() call.
|
|
20
|
+
*
|
|
21
|
+
* context identifies *whose* question this is (see PendingQuestion's own
|
|
22
|
+
* doc comment) — without it, listQuestions() would have no way to scope
|
|
23
|
+
* down to one agent/conversation, and every caller would see every
|
|
24
|
+
* pending question this whole process has ever raised.
|
|
25
|
+
*
|
|
26
|
+
* onPending, when given, is how a live caller (adapters/http.ts's
|
|
27
|
+
* streaming route passes `(q) => log('question:pending', q)`, which
|
|
28
|
+
* onEvent forwards straight onto that turn's SSE connection — see
|
|
29
|
+
* playground.ts's own inline card for it) finds out immediately instead
|
|
30
|
+
* of having to poll listQuestions(). It's a notification only, not a
|
|
31
|
+
* routing key: the question is registered in the same shared
|
|
32
|
+
* questionsById either way, so answerQuestion() works regardless of
|
|
33
|
+
* whether anything was listening for onPending in the first place.
|
|
34
|
+
*
|
|
35
|
+
* Omitted entirely (a plain CLI run, or any caller with no live channel
|
|
36
|
+
* of its own) falls back to a blocking terminal prompt instead of a
|
|
37
|
+
* pending entry nothing could ever answer — same reasoning
|
|
38
|
+
* ConsoleApprover exists for permission asks with nowhere else to go.
|
|
39
|
+
*
|
|
40
|
+
* Named `system_ask_user`, not the bare `ask_user` an agent author would
|
|
41
|
+
* naturally reach for on their own — see system-tools/read_file.ts's own
|
|
42
|
+
* doc comment on the same `system_` prefix for the full reasoning
|
|
43
|
+
* (unlikely to collide by accident, and run-agent.ts's own
|
|
44
|
+
* systemToolInstances gate bypass treats a same-named override as a
|
|
45
|
+
* deliberate opt-out when one does happen). It matters even more here
|
|
46
|
+
* than for read_file: this tool *is* a human's only way to answer the
|
|
47
|
+
* agent in the first place, so it can never be gated at all without a
|
|
48
|
+
* real deadlock, not just a redundant check — a name any other tool
|
|
49
|
+
* could plausibly reach for by accident would risk silently losing that
|
|
50
|
+
* guarantee, not just risk a surprising override. */
|
|
51
|
+
export declare function createAskUserTool(context: {
|
|
52
|
+
agent: string;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
}, onPending?: (question: PendingQuestion) => void): ToolDefinition;
|
|
55
|
+
/** Oldest first, same ordering reasoning web/web-approver.ts's listApprovals
|
|
56
|
+
* uses — whichever question has been waiting longest surfaces first.
|
|
57
|
+
* Unfiltered (both omitted) returns every question pending anywhere in
|
|
58
|
+
* this process — real uses (an operator's own admin view, say) should
|
|
59
|
+
* filter, since that's every conversation's questions mixed together. */
|
|
60
|
+
export declare function listQuestions(filter?: {
|
|
61
|
+
agent?: string;
|
|
62
|
+
sessionId?: string;
|
|
63
|
+
}): PendingQuestion[];
|
|
64
|
+
/** Peek at a pending question's own entry (its agent/sessionId, in
|
|
65
|
+
* particular) without answering it — adapters/http.ts needs this *before*
|
|
66
|
+
* calling answerQuestion() below, to know which turn's early-return race
|
|
67
|
+
* (see its own sessionTurns) to resume once this one's answered. */
|
|
68
|
+
export declare function findQuestion(id: string): PendingQuestion | undefined;
|
|
69
|
+
/** Returns false for an unknown/already-answered/timed-out id, so the
|
|
70
|
+
* caller can report that distinctly (a 404) instead of silently
|
|
71
|
+
* no-opping — same convention web/web-approver.ts's decideApproval uses. */
|
|
72
|
+
export declare function answerQuestion(id: string, answer: string): boolean;
|