loopengine 0.0.18 → 0.0.20
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/README.md +34 -3
- 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 +168 -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/{run-agent.d.ts → core/run-agent.d.ts} +77 -9
- package/dist/{run-agent.js → core/run-agent.js} +213 -44
- package/dist/{session-store.d.ts → core/session-store.d.ts} +13 -1
- 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/index.d.ts +19 -16
- package/dist/index.js +16 -9
- package/package.json +32 -26
- 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/{model-calls → core/model-calls}/openai-model-call.js +0 -0
- /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
- /package/dist/{vector-index.js → core/vector-index.js} +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { RuleSet } from 'actauth';
|
|
2
|
-
import type { AgentConfig, ToolDefinition, ToolSchema } from '#agent-config.js';
|
|
1
|
+
import { RuleSet, type Approver } from 'actauth';
|
|
2
|
+
import type { AgentConfig, ToolDefinition, ToolSchema } from '#core/agent-config.js';
|
|
3
|
+
import { type PendingQuestion } from './system-tools/index.js';
|
|
4
|
+
import type { LoopEvent } from './loop-events.js';
|
|
5
|
+
export { systemTools } from './system-tools/index.js';
|
|
6
|
+
export type * from './loop-events.js';
|
|
7
|
+
export declare const systemSkillsDir: string;
|
|
8
|
+
export declare function dedupeToolsByName(list: ToolDefinition[]): ToolDefinition[];
|
|
3
9
|
export interface ModelContentBlock {
|
|
4
10
|
type: string;
|
|
5
11
|
/** text blocks. */
|
|
@@ -16,6 +22,13 @@ export interface ModelContentBlock {
|
|
|
16
22
|
content?: string;
|
|
17
23
|
/** tool_result blocks: true if the tool call failed or was denied. */
|
|
18
24
|
is_error?: boolean;
|
|
25
|
+
/** tool_result blocks: the actauth decision's own reason this call was
|
|
26
|
+
* allowed (e.g. "matched rule '...'"). Extra metadata riding alongside
|
|
27
|
+
* `content`, not folded into it — every model-calls/* adapter
|
|
28
|
+
* explicitly whitelists which fields it forwards to a real model API,
|
|
29
|
+
* so this never reaches the model, only a caller (the playground)
|
|
30
|
+
* inspecting stored history directly. */
|
|
31
|
+
reason?: string;
|
|
19
32
|
}
|
|
20
33
|
export interface ModelResponse {
|
|
21
34
|
stop_reason: string;
|
|
@@ -37,8 +50,10 @@ export interface Message {
|
|
|
37
50
|
/** Swap the Anthropic SDK in here — this is the only seam a real API call needs. */
|
|
38
51
|
export type ModelCall = (messages: Message[], system: string, tools: ToolSchema[]) => Promise<ModelResponse>;
|
|
39
52
|
export interface RunAgentOptions {
|
|
40
|
-
/** Emitted at each loop step for callers that want visibility (logging, UI, tests)
|
|
41
|
-
|
|
53
|
+
/** Emitted at each loop step for callers that want visibility (logging, UI, tests) —
|
|
54
|
+
* see loop-events.ts's own header comment for why this is a single typed
|
|
55
|
+
* union rather than a string name plus untyped detail. Default: no-op. */
|
|
56
|
+
onEvent?: (event: LoopEvent) => void;
|
|
42
57
|
/** The ActAuth tenant this call runs as — feeds the Gate's scope, so it
|
|
43
58
|
* governs which rules apply. runAgent() never sees a request, so it
|
|
44
59
|
* can't call AgentConfig.tenantFor itself; adapters/http.ts resolves it
|
|
@@ -46,6 +61,45 @@ export interface RunAgentOptions {
|
|
|
46
61
|
* omitting it — every standalone/CLI caller (which never has a request
|
|
47
62
|
* to resolve tenantFor from) gets that default automatically. */
|
|
48
63
|
tenant?: string;
|
|
64
|
+
/** This call's own session id (the *raw* one — see adapters/http.ts's
|
|
65
|
+
* own rawSessionId/storageSessionId distinction, this is the former),
|
|
66
|
+
* if the caller has one. runAgent() never sees a request or a
|
|
67
|
+
* SessionStore itself, so it can't derive this on its own — passed
|
|
68
|
+
* through purely so a pending ask_user question can be tagged with
|
|
69
|
+
* which session raised it (see system-tools/ask_user.ts's own
|
|
70
|
+
* PendingQuestion.sessionId), letting a caller list/answer questions
|
|
71
|
+
* scoped to one conversation instead of every one this process has ever
|
|
72
|
+
* seen. Omitted (a standalone script, most CLI usage) just means a
|
|
73
|
+
* pending question can't be session-scoped — still fine, it's still
|
|
74
|
+
* agent-scoped either way. */
|
|
75
|
+
sessionId?: string;
|
|
76
|
+
/** Default Approver for this call's 'ask' decisions, when
|
|
77
|
+
* AgentConfig.approver isn't set — the seam an adapter uses to pick
|
|
78
|
+
* whichever approver actually fits its own channel (ConsoleApprover for
|
|
79
|
+
* a real terminal, a web-based one for an HTTP-served conversation,
|
|
80
|
+
* ...) without every agent needing to hardcode one itself. An explicit
|
|
81
|
+
* AgentConfig.approver still wins outright over this when the agent
|
|
82
|
+
* author set one on purpose — same as it always has (see Gate
|
|
83
|
+
* construction below) — this only fills in when that's absent. Default,
|
|
84
|
+
* if neither is given: actauth's own ConsoleApprover, same as always. */
|
|
85
|
+
approver?: Approver;
|
|
86
|
+
/** Fired the instant the system ask_user tool registers a new pending
|
|
87
|
+
* question — this is the seam that decides between a real, answerable
|
|
88
|
+
* pending question (something adapters/http.ts's own /questions REST
|
|
89
|
+
* endpoints, or an SSE push, can resolve later) and a blocking terminal
|
|
90
|
+
* prompt with nowhere else to go (see system-tools/ask_user.ts's own
|
|
91
|
+
* promptOnConsole).
|
|
92
|
+
*
|
|
93
|
+
* Deliberately its own option, not inferred from onEvent above —
|
|
94
|
+
* onEvent means "please tell me about loop events" (adapters/cli.ts
|
|
95
|
+
* passes one purely to log them), not "I have a way to actually answer
|
|
96
|
+
* a question later." Conflating the two was a real bug: it made a
|
|
97
|
+
* plain `loopengine run` hang forever registering an unanswerable
|
|
98
|
+
* question instead of just prompting in the terminal it's already
|
|
99
|
+
* attached to — confirmed live before this existed. Only an adapter
|
|
100
|
+
* that genuinely has an answering mechanism (adapters/http.ts, for both
|
|
101
|
+
* its routes) should pass this. */
|
|
102
|
+
onQuestionPending?: (question: PendingQuestion) => void;
|
|
49
103
|
}
|
|
50
104
|
export interface RunAgentResult {
|
|
51
105
|
/** Final assistant text for this turn. */
|
|
@@ -61,11 +115,25 @@ export interface RunAgentResult {
|
|
|
61
115
|
* only works if `history` only ever grows, which stops being true the
|
|
62
116
|
* moment recovery can shrink/rewrite it, not just extend it. */
|
|
63
117
|
newMessages: Message[];
|
|
64
|
-
/** Set
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
118
|
+
/** Set when the loop stopped for a reason other than the model
|
|
119
|
+
* producing a final answer with no more tool_use blocks — `text` in
|
|
120
|
+
* either case is a synthetic notice, not something the model said.
|
|
121
|
+
* Absent on a normal finish.
|
|
122
|
+
*
|
|
123
|
+
* 'max_turns': hit config.maxTurns without a final answer.
|
|
124
|
+
*
|
|
125
|
+
* 'denied': a human denied at least one requested tool call this turn
|
|
126
|
+
* (see actauth's own 'ask'/'deny' decisions) — the loop stops right
|
|
127
|
+
* there instead of feeding "denied: ..." back to the model and letting
|
|
128
|
+
* it keep going on its own, the same way Claude Code itself stops an
|
|
129
|
+
* entire pending batch rather than quietly carrying out the parts you
|
|
130
|
+
* didn't object to. A denial cancels the *whole* turn's batch, not
|
|
131
|
+
* just the call(s) that were themselves denied — any other call the
|
|
132
|
+
* model requested in the same turn, even one already approved, never
|
|
133
|
+
* runs either (see the loop's own body); it gets a "skipped", not a
|
|
134
|
+
* "denied", tool_result, since it was never evaluated against its own
|
|
135
|
+
* rule. */
|
|
136
|
+
stopReason?: 'max_turns' | 'denied';
|
|
69
137
|
}
|
|
70
138
|
/** Resolves AgentConfig.rules the same way skillsDirs resolves its own
|
|
71
139
|
* default: an inline array is used as-is (full 3-segment scopePattern,
|
|
@@ -15,18 +15,39 @@ import { Reflow } from 'reflowkit';
|
|
|
15
15
|
import { loadAgentModule } from './discover-agents.js';
|
|
16
16
|
import { agentAsTool } from './agent-as-tool.js';
|
|
17
17
|
import { loadGatewayToolsFromDir } from './gateway-tools.js';
|
|
18
|
+
import { systemTools, createAskUserTool } from './system-tools/index.js';
|
|
19
|
+
export { systemTools } from './system-tools/index.js';
|
|
18
20
|
// 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
|
-
|
|
21
|
+
// not process.cwd() — the same reasoning core/agent-registry.ts's own
|
|
22
|
+
// agentsDir uses. In dev (tsx), this file lives in core/ with agents/ one
|
|
23
|
+
// level up, at repo root. In the built dist/, this file is
|
|
24
|
+
// dist/core/run-agent.js with dist/agents/**/*.js (tsc-compiled,
|
|
25
|
+
// preserving the source tree's shape) one level up from *it* the same
|
|
26
|
+
// way — cwd is irrelevant to either case, only "one level up from this
|
|
27
|
+
// module" is reliably right in both. skillsDirs/rules don't need this:
|
|
28
|
+
// actauth.yml/SKILL.md are plain data copied verbatim into the image (see
|
|
29
|
+
// Dockerfile), so they exist at the same cwd-relative path either way; a
|
|
30
|
+
// tools/index.ts is real code that only exists compiled, at a different
|
|
31
|
+
// relative location, once dist/ is what's actually running.
|
|
32
|
+
const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
|
|
33
|
+
// Same "resolved relative to this file's own location" reasoning as
|
|
34
|
+
// agentsRootDir above, but system-skills/ is a same-level sibling of
|
|
35
|
+
// run-agent.ts itself (both live directly under core/ — see package.json's
|
|
36
|
+
// own "files" list), not one level up the way agents/ is, so this one
|
|
37
|
+
// stays a direct join with no '..'.
|
|
38
|
+
export const systemSkillsDir = join(dirname(fileURLToPath(import.meta.url)), 'system-skills');
|
|
39
|
+
// Tools every agent gets (see systemTools' own doc comment) are merged
|
|
40
|
+
// in *first*, not last — combined with keeping each name's *last*
|
|
41
|
+
// occurrence (a plain Map naturally does this: re-setting an existing
|
|
42
|
+
// key overwrites its value without moving it), that means an agent's
|
|
43
|
+
// own tool of the same name (config.tools, a subagent, a gateway source)
|
|
44
|
+
// always wins over the system default, never the reverse. Also used to
|
|
45
|
+
// dedupe the *array itself*, not just resolution at call time — without
|
|
46
|
+
// this, a name collision would still show up twice in toolSchemas,
|
|
47
|
+
// confusing (or rejected outright by) a real model API.
|
|
48
|
+
export function dedupeToolsByName(list) {
|
|
49
|
+
return [...new Map(list.map((t) => [t.name, t])).values()];
|
|
50
|
+
}
|
|
30
51
|
/** ContextClip only ever needs a flat string per message to estimate
|
|
31
52
|
* token usage — it doesn't need to understand tool_use/tool_result
|
|
32
53
|
* structure, so this is a one-way, read-only projection, never something
|
|
@@ -241,28 +262,42 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
241
262
|
// gateway-tools.ts) are both merged in on top either way — see
|
|
242
263
|
// loadSubagentAsTools's own doc comment for why neither is gated by
|
|
243
264
|
// whether `tools` was explicit.
|
|
244
|
-
|
|
265
|
+
// systemTools go first and are deduped by name (dedupeToolsByName keeps
|
|
266
|
+
// each name's *last* occurrence) — so config.tools/subagents/gateway
|
|
267
|
+
// tools always win over a same-named system default, never the
|
|
268
|
+
// reverse, even though systemTools is merged in unconditionally
|
|
269
|
+
// (unlike the others below, it's not gated on `tools` being omitted).
|
|
270
|
+
// ask_user is built fresh per call, not part of the static systemTools
|
|
271
|
+
// array — its onPending needs options.onQuestionPending, not `log`
|
|
272
|
+
// (options.onEvent's own fallback) — see RunAgentOptions.onQuestionPending's
|
|
273
|
+
// own doc comment for why those two are deliberately not the same thing.
|
|
274
|
+
const askUserTool = createAskUserTool({ agent: config.name, sessionId: options.sessionId }, options.onQuestionPending);
|
|
275
|
+
const tools = dedupeToolsByName([
|
|
276
|
+
...systemTools,
|
|
277
|
+
askUserTool,
|
|
245
278
|
...(config.tools ?? (await loadDefaultTools(config))),
|
|
246
279
|
...(await loadSubagentAsTools(config)),
|
|
247
280
|
...(await loadGatewayToolsFromDir(join(agentsRootDir, config.name))),
|
|
248
|
-
];
|
|
281
|
+
]);
|
|
249
282
|
// Omitted entirely (undefined): default to this agent's own
|
|
250
283
|
// agents/<name>/skills — the folder-form convention every agent in
|
|
251
284
|
// this repo already follows (see agent-config.ts's skillsDirs doc
|
|
252
|
-
// comment). An explicit `[]` is a real opt-out
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
285
|
+
// comment). An explicit `[]` is a real opt-out of the agent's *own*
|
|
286
|
+
// skills dir, but systemSkillsDir is still always included below —
|
|
287
|
+
// same "not opt-out-able the normal way" as systemTools above.
|
|
288
|
+
// SkillGarden's own missing-directory handling (discoverSkillFiles
|
|
289
|
+
// walks best-effort, no throw) is what makes pointing this at a folder
|
|
290
|
+
// that doesn't exist (a flat-file agent with no skills at all)
|
|
291
|
+
// harmless: an empty index, not an error.
|
|
257
292
|
const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
|
|
258
|
-
const skillGarden =
|
|
259
|
-
const skillIndex = skillGarden
|
|
293
|
+
const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 });
|
|
294
|
+
const skillIndex = skillGarden.buildIndex().included;
|
|
260
295
|
// Also the tail-preservation window recover() below relies on — kept as
|
|
261
296
|
// one named constant so the two can never drift out of sync.
|
|
262
297
|
const tailMessages = 4;
|
|
263
298
|
const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages });
|
|
264
299
|
const rules = loadRules(config);
|
|
265
|
-
const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
|
|
300
|
+
const gate = new Gate(rules, config.approver ?? options.approver ?? new ConsoleApprover());
|
|
266
301
|
// No explicit isSafeTool: fall back to each called tool's own `safe`
|
|
267
302
|
// flag (looked up by name) rather than defaulting every tool to unsafe
|
|
268
303
|
// outright — see ToolDefinition.safe's own doc comment for why this is
|
|
@@ -294,7 +329,7 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
294
329
|
const priorCount = currentMessages.length - newMessages.length;
|
|
295
330
|
const priorPortion = currentMessages.slice(0, priorCount);
|
|
296
331
|
const result = await contextClip.recover(priorPortion.map(flattenForContextClip));
|
|
297
|
-
log('reflow:recover',
|
|
332
|
+
log({ type: 'reflow:recover', from: currentMessages.length, to: result.messages.length + newMessages.length });
|
|
298
333
|
if (result.action === 'unchanged')
|
|
299
334
|
return currentMessages;
|
|
300
335
|
// Same tail-preservation reuse as before, just scoped to
|
|
@@ -320,6 +355,19 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
320
355
|
},
|
|
321
356
|
});
|
|
322
357
|
const toolsByName = new Map(tools.map((t) => [t.name, t]));
|
|
358
|
+
// Object identity, not name — an agent that overrides a system tool's
|
|
359
|
+
// name with its own ToolDefinition (dedupeToolsByName's own "config
|
|
360
|
+
// wins" rule above) is a deliberate opt-out, and that override should
|
|
361
|
+
// still go through the agent's own rules like anything else; only the
|
|
362
|
+
// genuine, unmodified system implementation always bypasses the gate.
|
|
363
|
+
// ask_user in particular can't be gated at all without a real deadlock:
|
|
364
|
+
// it's the mechanism a human uses to answer the agent, so requiring a
|
|
365
|
+
// human decision just to *ask* one is circular, not just redundant —
|
|
366
|
+
// and every default_decision this repo's own agents actually use
|
|
367
|
+
// ('ask' or 'deny', see loadRules' own doc comment) would otherwise
|
|
368
|
+
// catch it, since neither system tool ever appears in an agent's own
|
|
369
|
+
// actauth.yml.
|
|
370
|
+
const systemToolInstances = new Set([...systemTools, askUserTool]);
|
|
323
371
|
const toolSchemas = tools.map(({ name, description, input_schema }) => ({
|
|
324
372
|
name,
|
|
325
373
|
description,
|
|
@@ -362,16 +410,16 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
362
410
|
if (turn > maxTurns) {
|
|
363
411
|
const text = `Stopped after ${maxTurns} turns without a final answer — the agent may be stuck in a loop.`;
|
|
364
412
|
pushMessage({ role: 'assistant', content: text });
|
|
365
|
-
log('loop:max_turns',
|
|
413
|
+
log({ type: 'loop:max_turns', maxTurns });
|
|
366
414
|
return { text, history: messages, newMessages, stopReason: 'max_turns' };
|
|
367
415
|
}
|
|
368
416
|
const budget = contextClip.check(messages.map(flattenForContextClip));
|
|
369
|
-
log('contextclip:check', budget);
|
|
417
|
+
log({ type: 'contextclip:check', ...budget });
|
|
370
418
|
if (budget.action === 'nudge' && budget.nudge)
|
|
371
419
|
pushMessage(budget.nudge);
|
|
372
420
|
const { value: response, recoveries } = await reflow.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
|
|
373
421
|
if (recoveries.length > 0)
|
|
374
|
-
log('reflow:recoveries', recoveries);
|
|
422
|
+
log({ type: 'reflow:recoveries', recoveries });
|
|
375
423
|
// The model's full response — text and tool_use blocks alike, with
|
|
376
424
|
// real ids — becomes this turn's assistant message verbatim. Every
|
|
377
425
|
// tool_use block emitted here gets a matching tool_result pushed
|
|
@@ -381,11 +429,34 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
381
429
|
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
|
382
430
|
if (toolUseBlocks.length === 0) {
|
|
383
431
|
const text = response.content.find((b) => b.type === 'text')?.text ?? '';
|
|
384
|
-
log('loop:done', text);
|
|
432
|
+
log({ type: 'loop:done', text });
|
|
385
433
|
return { text, history: messages, newMessages };
|
|
386
434
|
}
|
|
435
|
+
// The model's own text alongside a tool_use request (its "I'll do X"
|
|
436
|
+
// preamble) is already durable via pushMessage above, but the only
|
|
437
|
+
// *live* SSE event that ever carries assistant text is 'done', which
|
|
438
|
+
// fires once at the very end of the whole turn — a live caller
|
|
439
|
+
// watching this turn in progress (adapters/http.ts's streaming route,
|
|
440
|
+
// the playground) would otherwise jump straight from "thinking..." to
|
|
441
|
+
// an approval/question card with zero explanation, and only ever see
|
|
442
|
+
// this sentence later, after a refresh replays stored history —
|
|
443
|
+
// confirmed live.
|
|
444
|
+
const preambleText = response.content.find((b) => b.type === 'text')?.text;
|
|
445
|
+
if (preambleText)
|
|
446
|
+
log({ type: 'assistant:text', text: preambleText });
|
|
387
447
|
const resultBlocks = [];
|
|
388
448
|
const approvedCalls = [];
|
|
449
|
+
const deniedTools = [];
|
|
450
|
+
// Keyed by tool_use id — approvedCalls (a toollane LaneCall) has no
|
|
451
|
+
// room for extra fields of its own, and toolLane.run's own results
|
|
452
|
+
// only carry `id`/`name`/status, not anything about the *decision*
|
|
453
|
+
// that let a call through in the first place (or what it was even
|
|
454
|
+
// called with). This is what lets the reason still get attached to
|
|
455
|
+
// the right tool_result once execution finishes below, and lets the
|
|
456
|
+
// 'tool:result' live event (see below) report the same args a
|
|
457
|
+
// resumed/refreshed view of this same call would show, without
|
|
458
|
+
// changing toollane's own types.
|
|
459
|
+
const approvedMetaById = new Map();
|
|
389
460
|
for (const block of toolUseBlocks) {
|
|
390
461
|
// Skill invocation injects instructions into context; it isn't a
|
|
391
462
|
// real tool call and doesn't need ActAuth gating or ToolLane
|
|
@@ -393,13 +464,43 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
393
464
|
// so it still needs a tool_result to answer it, same as any other.
|
|
394
465
|
if (block.name === 'Skill' && skillGarden) {
|
|
395
466
|
const body = skillGarden.invoke(block.input.skill, block.input?.args);
|
|
396
|
-
log('skillgarden:invoke', block.input.skill);
|
|
467
|
+
log({ type: 'skillgarden:invoke', skill: block.input.skill });
|
|
397
468
|
resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
|
|
398
469
|
continue;
|
|
399
470
|
}
|
|
471
|
+
// See systemToolInstances' own doc comment above — bypasses
|
|
472
|
+
// gate.evaluate entirely, not just auto-approves through it, since
|
|
473
|
+
// ask_user itself can be the approver's own only way to ask a human
|
|
474
|
+
// anything in the first place.
|
|
475
|
+
if (systemToolInstances.has(toolsByName.get(block.name))) {
|
|
476
|
+
log({ type: 'actauth:decision', tool: block.name, decision: 'allow', reason: 'system tool — always allowed' });
|
|
477
|
+
approvedMetaById.set(block.id, { reason: 'system tool — always allowed', args: block.input ?? {} });
|
|
478
|
+
// Fired the moment the decision is in, not once execution finishes
|
|
479
|
+
// (see the 'tool:started' vs 'tool:result' split below) — a system
|
|
480
|
+
// tool can still be slow, and this is the one path that had no
|
|
481
|
+
// interactive card of its own to show something sooner.
|
|
482
|
+
log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: 'system tool — always allowed' });
|
|
483
|
+
approvedCalls.push({
|
|
484
|
+
id: block.id,
|
|
485
|
+
name: block.name,
|
|
486
|
+
execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
|
|
487
|
+
});
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
400
490
|
const decision = await gate.evaluate(block.name, block.input ?? {}, scope);
|
|
401
|
-
log('actauth:decision',
|
|
491
|
+
log({ type: 'actauth:decision', tool: block.name, decision: decision.decision, reason: decision.reason });
|
|
402
492
|
if (decision.decision === 'allow') {
|
|
493
|
+
approvedMetaById.set(block.id, { reason: decision.reason, args: block.input ?? {} });
|
|
494
|
+
// Fired the instant the decision is in, not once execution
|
|
495
|
+
// finishes below — closes what would otherwise be a multi-second
|
|
496
|
+
// silent gap while a slow call runs. Always emitted, whether or
|
|
497
|
+
// not a human was actually asked live: run-agent.ts itself has no
|
|
498
|
+
// notion of "already shown elsewhere" — that's a rendering
|
|
499
|
+
// decision, and belongs to whichever channel adapter is actually
|
|
500
|
+
// presenting this turn (see web/playground.ts's own
|
|
501
|
+
// wasAskedInteractively, which decides whether this duplicates an
|
|
502
|
+
// approval card already on screen), not to the engine loop.
|
|
503
|
+
log({ type: 'tool:started', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason });
|
|
403
504
|
approvedCalls.push({
|
|
404
505
|
id: block.id,
|
|
405
506
|
name: block.name,
|
|
@@ -410,31 +511,99 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
410
511
|
// Every requested tool gets exactly one tool_result back — a
|
|
411
512
|
// denied call is not simply dropped, or the model has no way to
|
|
412
513
|
// tell "denied" apart from "hasn't run yet" and may just
|
|
413
|
-
// re-request it forever.
|
|
514
|
+
// re-request it forever. Recorded even though this turn is about
|
|
515
|
+
// to stop (see deniedTools below) — a later message continuing
|
|
516
|
+
// this same session still needs a complete, consistent history,
|
|
517
|
+
// not a dangling tool_use with no answer.
|
|
518
|
+
deniedTools.push(block.name);
|
|
414
519
|
resultBlocks.push({
|
|
415
520
|
type: 'tool_result',
|
|
416
521
|
tool_use_id: block.id,
|
|
417
522
|
content: `denied: ${decision.reason}`,
|
|
418
523
|
is_error: true,
|
|
419
524
|
});
|
|
525
|
+
// Always emitted — see the 'allow' branch's own comment above on
|
|
526
|
+
// why this engine loop no longer decides whether a live UI
|
|
527
|
+
// already has some other representation of this decision on
|
|
528
|
+
// screen (an approval card that went through an interactive
|
|
529
|
+
// ask). That's a rendering call, made by whichever channel
|
|
530
|
+
// adapter is presenting this turn.
|
|
531
|
+
log({ type: 'tool:result', id: block.id, tool: block.name, args: block.input ?? {}, detailText: decision.reason, statusText: 'Denied.' });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (deniedTools.length > 0) {
|
|
535
|
+
// A denial cancels the *whole* batch, not just the call(s) that
|
|
536
|
+
// were themselves denied — an approved sibling call from the same
|
|
537
|
+
// turn never runs at all, same reasoning Claude Code itself stops
|
|
538
|
+
// an entire pending batch rather than quietly carrying out the
|
|
539
|
+
// parts you didn't object to. Every approved call still gets
|
|
540
|
+
// exactly one tool_result back regardless (the model emitted a
|
|
541
|
+
// tool_use for it, so it needs an answer) — "skipped", not
|
|
542
|
+
// "denied": it was never evaluated against its own rule, a
|
|
543
|
+
// *different* call in the same turn was what stopped it.
|
|
544
|
+
for (const call of approvedCalls) {
|
|
545
|
+
log({ type: 'loop:skipped', name: call.name, deniedTools });
|
|
546
|
+
const skipReason = `a sibling tool call in this turn (${deniedTools.join(', ')}) was denied`;
|
|
547
|
+
resultBlocks.push({
|
|
548
|
+
type: 'tool_result',
|
|
549
|
+
tool_use_id: call.id,
|
|
550
|
+
content: `skipped: ${skipReason}`,
|
|
551
|
+
is_error: true,
|
|
552
|
+
});
|
|
553
|
+
// A skipped call never went through gate.evaluate at all — no
|
|
554
|
+
// approver, no interactive card, nothing live ever showed it was
|
|
555
|
+
// even requested. Same reasoning as the straight-denied case
|
|
556
|
+
// just above.
|
|
557
|
+
log({ type: 'tool:result', id: call.id, tool: call.name, args: approvedMetaById.get(call.id)?.args, detailText: skipReason, statusText: 'Skipped.' });
|
|
420
558
|
}
|
|
421
559
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
type: '
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
560
|
+
else {
|
|
561
|
+
// result.id is the same id LaneCall.id was given above — the exact
|
|
562
|
+
// per-call identity that makes it possible to link a result back
|
|
563
|
+
// to the specific tool_use block that requested it, even when
|
|
564
|
+
// several calls ran in the same parallel lane. The old
|
|
565
|
+
// flattened-text design could only link by tool *name*, ambiguous
|
|
566
|
+
// the moment two calls to the same tool ran in one turn.
|
|
567
|
+
for await (const result of toolLane.run(approvedCalls)) {
|
|
568
|
+
const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
|
|
569
|
+
const meta = approvedMetaById.get(result.id);
|
|
570
|
+
log({ type: 'toollane:result', name: result.name, summary });
|
|
571
|
+
resultBlocks.push({
|
|
572
|
+
type: 'tool_result',
|
|
573
|
+
tool_use_id: result.id,
|
|
574
|
+
content: summary,
|
|
575
|
+
is_error: result.status === 'rejected',
|
|
576
|
+
reason: meta?.reason,
|
|
577
|
+
});
|
|
578
|
+
// Always emitted — this is what turns the earlier 'tool:started'
|
|
579
|
+
// event's "Running…" placeholder into the real outcome (matched
|
|
580
|
+
// by the same id), for every call regardless of how it got
|
|
581
|
+
// approved. See the 'allow' branch's own comment above: deciding
|
|
582
|
+
// whether this duplicates an approval card already on screen is
|
|
583
|
+
// a rendering call for the channel adapter presenting this turn,
|
|
584
|
+
// not something this engine loop tracks.
|
|
585
|
+
log({
|
|
586
|
+
type: 'tool:result',
|
|
587
|
+
id: result.id,
|
|
588
|
+
tool: result.name,
|
|
589
|
+
args: meta?.args,
|
|
590
|
+
detailText: meta?.reason,
|
|
591
|
+
statusText: result.status === 'fulfilled' ? 'Approved.' : 'Error.',
|
|
592
|
+
});
|
|
593
|
+
}
|
|
437
594
|
}
|
|
438
595
|
pushMessage({ role: 'user', content: resultBlocks });
|
|
596
|
+
// Stop here, don't loop back for another model call — same reasoning
|
|
597
|
+
// Claude Code itself stops and waits for you rather than working
|
|
598
|
+
// around a tool call you just rejected, instead of silently handing
|
|
599
|
+
// "denied: ..." to the model and letting it decide on its own what to
|
|
600
|
+
// try next (retry the same tool, reach for a different one, or just
|
|
601
|
+
// talk its way past the refusal).
|
|
602
|
+
if (deniedTools.length > 0) {
|
|
603
|
+
const text = `Stopped — you denied: ${deniedTools.join(', ')}. Send another message to continue.`;
|
|
604
|
+
pushMessage({ role: 'assistant', content: text });
|
|
605
|
+
log({ type: 'loop:denied', deniedTools });
|
|
606
|
+
return { text, history: messages, newMessages, stopReason: 'denied' };
|
|
607
|
+
}
|
|
439
608
|
}
|
|
440
609
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
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
|
|
@@ -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;
|