loopengine 0.0.20 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +245 -0
- package/README.md +53 -10
- package/dist/core/agent-config.d.ts +282 -3
- 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 +60 -29
- package/dist/core/model-calls/openai-model-call.js +33 -17
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +143 -15
- package/dist/core/run-agent.js +321 -66
- package/dist/core/session-store.d.ts +2 -1
- 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/core/vector-index.js +1 -1
- package/dist/package.json +6 -0
- package/package.json +16 -6
- package/protocol/loop-event.schema.json +257 -0
- /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
//
|
|
14
14
|
// stop_reason is normalized to the vocabulary anthropic-model-call.ts
|
|
15
15
|
// already established ('end_turn' / 'tool_use' / 'max_tokens') rather than
|
|
16
|
-
// passed through as OpenAI's own finish_reason strings —
|
|
16
|
+
// passed through as OpenAI's own finish_reason strings — recovery.ts's
|
|
17
17
|
// defaultIsTruncated checks a translated ModelResponse for stop_reason ===
|
|
18
18
|
// 'max_tokens', so a provider-agnostic caller wiring up onTruncated later
|
|
19
19
|
// gets the same behavior regardless of which ModelCall is plugged in.
|
|
@@ -35,22 +35,31 @@ export function toMessageParams(message) {
|
|
|
35
35
|
.map((block) => block.text ?? '')
|
|
36
36
|
.join('');
|
|
37
37
|
const toolUseBlocks = message.content.filter((block) => block.type === 'tool_use');
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
38
|
+
// DeepSeek's thinking mode requires the exact reasoning that produced
|
|
39
|
+
// this message — tool_calls included — echoed back verbatim on every
|
|
40
|
+
// later request that still includes this message, for as long as the
|
|
41
|
+
// conversation keeps passing `tools` at all (confirmed live: dropping
|
|
42
|
+
// it gets the whole request rejected with a 400, not just ignored).
|
|
43
|
+
// Round-tripped opaquely here, never inspected — see
|
|
44
|
+
// ModelContentBlock's own 'thinking' block doc comment (run-agent.ts).
|
|
45
|
+
const thinkingBlock = message.content.find((block) => block.type === 'thinking');
|
|
46
|
+
const result = {
|
|
47
|
+
role: 'assistant',
|
|
48
|
+
// Required unless tool_calls is set — an empty string here (no
|
|
49
|
+
// text, only tool calls) is rejected by the API, so this must be
|
|
50
|
+
// null, not ''.
|
|
51
|
+
content: text || null,
|
|
52
|
+
tool_calls: toolUseBlocks.length
|
|
53
|
+
? toolUseBlocks.map((block) => ({
|
|
54
|
+
id: block.id,
|
|
55
|
+
type: 'function',
|
|
56
|
+
function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) },
|
|
57
|
+
}))
|
|
58
|
+
: undefined,
|
|
59
|
+
};
|
|
60
|
+
if (thinkingBlock?.text)
|
|
61
|
+
result.reasoning_content = thinkingBlock.text;
|
|
62
|
+
return [result];
|
|
54
63
|
}
|
|
55
64
|
// Every non-assistant, block-structured Message this loop ever builds is
|
|
56
65
|
// one turn's tool_result blocks bundled together (see run-agent.ts's
|
|
@@ -72,6 +81,13 @@ export function toStopReason(finishReason) {
|
|
|
72
81
|
}
|
|
73
82
|
export function toModelResponse(choice) {
|
|
74
83
|
const content = [];
|
|
84
|
+
// Ordered first — reasoning precedes the response it produced. Only
|
|
85
|
+
// DeepSeek's thinking-mode responses carry this; a plain response
|
|
86
|
+
// simply has no reasoning_content, so nothing gets pushed and
|
|
87
|
+
// toMessageParams above has nothing to echo back for this message.
|
|
88
|
+
const reasoningContent = choice.message.reasoning_content;
|
|
89
|
+
if (reasoningContent)
|
|
90
|
+
content.push({ type: 'thinking', text: reasoningContent });
|
|
75
91
|
if (choice.message.content)
|
|
76
92
|
content.push({ type: 'text', text: choice.message.content });
|
|
77
93
|
for (const call of choice.message.tool_calls ?? []) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type RecoveryAction = 'prompt_too_long' | 'media_too_large' | 'truncated_retry';
|
|
2
|
+
export interface RecoveryResult<T> {
|
|
3
|
+
value: T;
|
|
4
|
+
/** Ordered log of what happened, empty if the first attempt just worked. */
|
|
5
|
+
recoveries: RecoveryAction[];
|
|
6
|
+
/** True if the final result is still truncated after exhausting retries
|
|
7
|
+
* (or if no onTruncated hook was provided at all). */
|
|
8
|
+
truncated: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface RecoveryOptions<TMessages> {
|
|
11
|
+
/** Called when a request is rejected as too large. Return a smaller
|
|
12
|
+
* message list to retry with. No default — an unhandled too-long
|
|
13
|
+
* error propagates rather than being guessed at. */
|
|
14
|
+
onPromptTooLong?: (messages: TMessages) => TMessages | Promise<TMessages>;
|
|
15
|
+
/** Called when a request is rejected for oversized media. Same
|
|
16
|
+
* fail-loud default as onPromptTooLong. */
|
|
17
|
+
onMediaTooLarge?: (messages: TMessages) => TMessages | Promise<TMessages>;
|
|
18
|
+
/** Called when a response comes back truncated. Return adjusted
|
|
19
|
+
* messages to retry with, or undefined to stop and accept the
|
|
20
|
+
* truncated result. */
|
|
21
|
+
onTruncated?: (messages: TMessages, attempt: number) => TMessages | undefined | Promise<TMessages | undefined>;
|
|
22
|
+
/** Cap on truncation-recovery attempts. Default 2. */
|
|
23
|
+
maxTruncationRetries?: number;
|
|
24
|
+
/** Cap on prompt-too-long/media-too-large recovery attempts combined —
|
|
25
|
+
* a safety limit so a broken recovery hook can't loop forever burning
|
|
26
|
+
* API calls. Default 3. */
|
|
27
|
+
maxRecoveryAttempts?: number;
|
|
28
|
+
/** Override the default Anthropic/OpenAI-shaped error classifier. */
|
|
29
|
+
isPromptTooLong?: (error: unknown) => boolean;
|
|
30
|
+
isMediaTooLarge?: (error: unknown) => boolean;
|
|
31
|
+
isTruncated?: (response: unknown) => boolean;
|
|
32
|
+
}
|
|
33
|
+
export declare function defaultIsPromptTooLong(error: unknown): boolean;
|
|
34
|
+
export declare function defaultIsMediaTooLarge(error: unknown): boolean;
|
|
35
|
+
export declare function defaultIsTruncated(response: unknown): boolean;
|
|
36
|
+
/** Wraps a model API call with reactive recovery — see this file's own
|
|
37
|
+
* header comment for the three cases it covers. */
|
|
38
|
+
export declare class Recovery<TMessages> {
|
|
39
|
+
private readonly onPromptTooLong?;
|
|
40
|
+
private readonly onMediaTooLarge?;
|
|
41
|
+
private readonly onTruncated?;
|
|
42
|
+
private readonly maxTruncationRetries;
|
|
43
|
+
private readonly maxRecoveryAttempts;
|
|
44
|
+
private readonly isPromptTooLong;
|
|
45
|
+
private readonly isMediaTooLarge;
|
|
46
|
+
private readonly isTruncated;
|
|
47
|
+
constructor(options?: RecoveryOptions<TMessages>);
|
|
48
|
+
call<T>(fn: (messages: TMessages) => Promise<T>, messages: TMessages): Promise<RecoveryResult<T>>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Heuristic defaults covering Anthropic- and OpenAI-shaped errors and
|
|
2
|
+
* responses. Deliberately not exhaustive — override via RecoveryOptions
|
|
3
|
+
* for other providers or stricter matching. Duck-typed on purpose, so
|
|
4
|
+
* this stays independent of either SDK's own error/response classes. */
|
|
5
|
+
function messageOf(error) {
|
|
6
|
+
if (error && typeof error === 'object' && 'message' in error) {
|
|
7
|
+
return String(error.message).toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
function codeOf(error) {
|
|
12
|
+
if (error && typeof error === 'object' && 'code' in error) {
|
|
13
|
+
return String(error.code).toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
return '';
|
|
16
|
+
}
|
|
17
|
+
function statusOf(error) {
|
|
18
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
19
|
+
const status = Number(error.status);
|
|
20
|
+
return Number.isNaN(status) ? undefined : status;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const TOO_LONG_PATTERNS = ['prompt is too long', 'too many tokens', 'maximum context length', 'context_length_exceeded', 'input length', 'exceeds the maximum'];
|
|
25
|
+
export function defaultIsPromptTooLong(error) {
|
|
26
|
+
const status = statusOf(error);
|
|
27
|
+
if (status !== 400 && status !== 413)
|
|
28
|
+
return false;
|
|
29
|
+
if (codeOf(error) === 'context_length_exceeded')
|
|
30
|
+
return true;
|
|
31
|
+
const message = messageOf(error);
|
|
32
|
+
return TOO_LONG_PATTERNS.some((pattern) => message.includes(pattern));
|
|
33
|
+
}
|
|
34
|
+
const MEDIA_PATTERNS = ['image', 'media', 'file size', 'attachment'];
|
|
35
|
+
const SIZE_PATTERNS = ['too large', 'exceeds', 'size limit', 'too big'];
|
|
36
|
+
export function defaultIsMediaTooLarge(error) {
|
|
37
|
+
const status = statusOf(error);
|
|
38
|
+
if (status !== 400 && status !== 413)
|
|
39
|
+
return false;
|
|
40
|
+
const message = messageOf(error);
|
|
41
|
+
const mentionsMedia = MEDIA_PATTERNS.some((pattern) => message.includes(pattern));
|
|
42
|
+
const mentionsSize = SIZE_PATTERNS.some((pattern) => message.includes(pattern));
|
|
43
|
+
return mentionsMedia && mentionsSize;
|
|
44
|
+
}
|
|
45
|
+
export function defaultIsTruncated(response) {
|
|
46
|
+
if (!response || typeof response !== 'object')
|
|
47
|
+
return false;
|
|
48
|
+
const r = response;
|
|
49
|
+
if (r.stop_reason === 'max_tokens')
|
|
50
|
+
return true; // Anthropic
|
|
51
|
+
const choices = r.choices;
|
|
52
|
+
if (Array.isArray(choices) && choices[0] && typeof choices[0] === 'object') {
|
|
53
|
+
const choice = choices[0];
|
|
54
|
+
if (choice.finish_reason === 'length')
|
|
55
|
+
return true; // OpenAI
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/** Wraps a model API call with reactive recovery — see this file's own
|
|
60
|
+
* header comment for the three cases it covers. */
|
|
61
|
+
export class Recovery {
|
|
62
|
+
onPromptTooLong;
|
|
63
|
+
onMediaTooLarge;
|
|
64
|
+
onTruncated;
|
|
65
|
+
maxTruncationRetries;
|
|
66
|
+
maxRecoveryAttempts;
|
|
67
|
+
isPromptTooLong;
|
|
68
|
+
isMediaTooLarge;
|
|
69
|
+
isTruncated;
|
|
70
|
+
constructor(options = {}) {
|
|
71
|
+
this.onPromptTooLong = options.onPromptTooLong;
|
|
72
|
+
this.onMediaTooLarge = options.onMediaTooLarge;
|
|
73
|
+
this.onTruncated = options.onTruncated;
|
|
74
|
+
this.maxTruncationRetries = options.maxTruncationRetries ?? 2;
|
|
75
|
+
this.maxRecoveryAttempts = options.maxRecoveryAttempts ?? 3;
|
|
76
|
+
this.isPromptTooLong = options.isPromptTooLong ?? defaultIsPromptTooLong;
|
|
77
|
+
this.isMediaTooLarge = options.isMediaTooLarge ?? defaultIsMediaTooLarge;
|
|
78
|
+
this.isTruncated = options.isTruncated ?? defaultIsTruncated;
|
|
79
|
+
}
|
|
80
|
+
async call(fn, messages) {
|
|
81
|
+
let currentMessages = messages;
|
|
82
|
+
const recoveries = [];
|
|
83
|
+
let value;
|
|
84
|
+
// Stage 1: recover from prompt-too-long / media-too-large, capped so
|
|
85
|
+
// a broken recovery hook can't loop forever burning API calls.
|
|
86
|
+
let attempts = 0;
|
|
87
|
+
for (;;) {
|
|
88
|
+
try {
|
|
89
|
+
value = await fn(currentMessages);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
attempts++;
|
|
94
|
+
if (attempts > this.maxRecoveryAttempts)
|
|
95
|
+
throw error;
|
|
96
|
+
if (this.isPromptTooLong(error) && this.onPromptTooLong) {
|
|
97
|
+
currentMessages = await this.onPromptTooLong(currentMessages);
|
|
98
|
+
recoveries.push('prompt_too_long');
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (this.isMediaTooLarge(error) && this.onMediaTooLarge) {
|
|
102
|
+
currentMessages = await this.onMediaTooLarge(currentMessages);
|
|
103
|
+
recoveries.push('media_too_large');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Stage 2: recover from output truncation, bounded.
|
|
110
|
+
let truncated = this.isTruncated(value);
|
|
111
|
+
let truncationAttempt = 0;
|
|
112
|
+
while (truncated && this.onTruncated && truncationAttempt < this.maxTruncationRetries) {
|
|
113
|
+
const nextMessages = await this.onTruncated(currentMessages, truncationAttempt);
|
|
114
|
+
if (nextMessages === undefined)
|
|
115
|
+
break;
|
|
116
|
+
currentMessages = nextMessages;
|
|
117
|
+
value = await fn(currentMessages);
|
|
118
|
+
recoveries.push('truncated_retry');
|
|
119
|
+
truncated = this.isTruncated(value);
|
|
120
|
+
truncationAttempt++;
|
|
121
|
+
}
|
|
122
|
+
return { value, recoveries, truncated };
|
|
123
|
+
}
|
|
124
|
+
}
|
package/dist/core/run-agent.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RuleSet, type Approver } from 'actauth';
|
|
2
|
-
import type { AgentConfig, ToolDefinition, ToolSchema } from '#core/agent-config.js';
|
|
2
|
+
import type { AgentConfig, ApproverChannel, QuestionHandler, ToolDefinition, ToolSchema } from '#core/agent-config.js';
|
|
3
3
|
import { type PendingQuestion } from './system-tools/index.js';
|
|
4
4
|
import type { LoopEvent } from './loop-events.js';
|
|
5
5
|
export { systemTools } from './system-tools/index.js';
|
|
@@ -8,7 +8,18 @@ export declare const systemSkillsDir: string;
|
|
|
8
8
|
export declare function dedupeToolsByName(list: ToolDefinition[]): ToolDefinition[];
|
|
9
9
|
export interface ModelContentBlock {
|
|
10
10
|
type: string;
|
|
11
|
-
/** text blocks
|
|
11
|
+
/** text blocks, and thinking blocks (a model's own chain-of-thought,
|
|
12
|
+
* e.g. DeepSeek's `reasoning_content` sibling field, or — if a
|
|
13
|
+
* model-calls/* adapter ever opts into it — Anthropic's own `thinking`
|
|
14
|
+
* content block). Reuses this same field rather than adding a
|
|
15
|
+
* thinking-only one, since both are just "a string of the model's own
|
|
16
|
+
* words." Carried opaquely: loopengine never reads or reasons about
|
|
17
|
+
* this text itself, only round-trips it back verbatim on the next
|
|
18
|
+
* request that includes this message — some reasoning-mode providers
|
|
19
|
+
* (DeepSeek's thinking mode, confirmed live; Anthropic's extended
|
|
20
|
+
* thinking, documented the same way) reject the request outright if a
|
|
21
|
+
* prior turn's reasoning gets silently dropped on the way back in,
|
|
22
|
+
* once the conversation has tool_calls in it. */
|
|
12
23
|
text?: string;
|
|
13
24
|
/** tool_use blocks: this call's own id (referenced by a later tool_result). */
|
|
14
25
|
id?: string;
|
|
@@ -35,7 +46,7 @@ export interface ModelResponse {
|
|
|
35
46
|
content: ModelContentBlock[];
|
|
36
47
|
}
|
|
37
48
|
/** loopengine's own conversation-message type — a superset of
|
|
38
|
-
*
|
|
49
|
+
* budget.ts's own `{role, content: string}`: `content` can also be a
|
|
39
50
|
* `ModelContentBlock[]`, the same block shape `ModelResponse.content`
|
|
40
51
|
* already used, so a model's tool_use requests and this loop's
|
|
41
52
|
* tool_result replies round-trip with real per-call identity
|
|
@@ -73,15 +84,32 @@ export interface RunAgentOptions {
|
|
|
73
84
|
* pending question can't be session-scoped — still fine, it's still
|
|
74
85
|
* agent-scoped either way. */
|
|
75
86
|
sessionId?: string;
|
|
76
|
-
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
87
|
+
/** Which channel this call is on — checked below against `'http'`,
|
|
88
|
+
* the only value `AgentConfig.httpNotifier` ever matches (see that
|
|
89
|
+
* field's own doc comment). Omitted (a standalone script, a bespoke
|
|
90
|
+
* dispatcher with no fixed channel identity) means `httpNotifier` is
|
|
91
|
+
* never consulted at all — `approver` below is the only thing that
|
|
92
|
+
* applies. */
|
|
93
|
+
channel?: ApproverChannel;
|
|
94
|
+
/** This call's own default Approver for 'ask' decisions, when
|
|
95
|
+
* `AgentConfig.httpNotifier` doesn't supply one (only possible on the
|
|
96
|
+
* `http` channel, and only when it lists `'approval'`) — the seam an
|
|
97
|
+
* adapter uses to pick whichever approver actually fits its own channel
|
|
98
|
+
* (ConsoleApprover for a real terminal, a fresh WebchatApprover per
|
|
99
|
+
* streamed turn, a WebhookApprover for a background dispatcher,
|
|
100
|
+
* ...) without every agent needing to hardcode one itself. `http`'s own
|
|
101
|
+
* `httpNotifier`, if it covers `'approval'`, still wins outright over
|
|
102
|
+
* this — the agent author's own explicit choice *for that channel*
|
|
103
|
+
* beats whatever the adapter would otherwise default to (see Gate
|
|
104
|
+
* construction below) — `cli`/`http_stream` have no such override at
|
|
105
|
+
* all, so this is the only thing that ever applies there. Default, if
|
|
106
|
+
* neither is given: actauth's own ConsoleApprover, same as always.
|
|
107
|
+
*
|
|
108
|
+
* Can be a DurableApprover (WebhookApprover, ...) instead of a live
|
|
109
|
+
* one — see HUMAN_IN_THE_LOOP.md. A background/cron dispatcher calling
|
|
110
|
+
* runAgent() directly is exactly this same kind of caller, and should
|
|
111
|
+
* pass its own durable default here the same way adapters/http.ts's
|
|
112
|
+
* stream route passes a fresh WebchatApprover. */
|
|
85
113
|
approver?: Approver;
|
|
86
114
|
/** Fired the instant the system ask_user tool registers a new pending
|
|
87
115
|
* question — this is the seam that decides between a real, answerable
|
|
@@ -100,6 +128,30 @@ export interface RunAgentOptions {
|
|
|
100
128
|
* that genuinely has an answering mechanism (adapters/http.ts, for both
|
|
101
129
|
* its routes) should pass this. */
|
|
102
130
|
onQuestionPending?: (question: PendingQuestion) => void;
|
|
131
|
+
/** This call's own default QuestionHandler — question-side sibling of
|
|
132
|
+
* `approver` above, same precedence (`AgentConfig.httpNotifier`, when it
|
|
133
|
+
* covers `'question'` on the `http` channel, still wins outright over
|
|
134
|
+
* this) and the same `LiveQuestionHandler | DurableQuestionHandler`
|
|
135
|
+
* union `approver` itself is, duck-typed the same way (see
|
|
136
|
+
* `isDurableQuestionHandler`). Default, if neither this nor
|
|
137
|
+
* `httpNotifier` supplies one: `new CliQuestionHandler()` — same role
|
|
138
|
+
* `new ConsoleApprover()` plays for `approver`.
|
|
139
|
+
*
|
|
140
|
+
* One real, current asymmetry versus `approver`: only the *durable*
|
|
141
|
+
* half of this union is actually wired into the loop below — a
|
|
142
|
+
* `system_ask_user` call only ever consults this field to decide
|
|
143
|
+
* live-vs-durable; the live case still always falls through to
|
|
144
|
+
* `onQuestionPending`/the tool's own independent live resolution
|
|
145
|
+
* (unchanged from before this field existed), never actually calling
|
|
146
|
+
* a live `QuestionHandler` resolved here. See HUMAN_IN_THE_LOOP.md's
|
|
147
|
+
* "Durable questions" section for why: fully wiring the live half too
|
|
148
|
+
* would mean moving system_ask_user's answer-collection inline into
|
|
149
|
+
* this loop (changing today's batching semantics — right now a
|
|
150
|
+
* pending question is deferred past the whole tool_use batch scan,
|
|
151
|
+
* unlike a live approval) and a breaking change to createAskUserTool's
|
|
152
|
+
* own signature — deliberately deferred rather than done half-carefully
|
|
153
|
+
* here. */
|
|
154
|
+
questionHandler?: QuestionHandler;
|
|
103
155
|
}
|
|
104
156
|
export interface RunAgentResult {
|
|
105
157
|
/** Final assistant text for this turn. */
|
|
@@ -109,7 +161,7 @@ export interface RunAgentResult {
|
|
|
109
161
|
* the caller: store/transmit it, don't inspect or mutate it. */
|
|
110
162
|
history: Message[];
|
|
111
163
|
/** Exactly what this turn added — safe to durably append regardless of
|
|
112
|
-
* whether
|
|
164
|
+
* whether compaction.ts's own recovery reshaped `history` mid-turn. A caller
|
|
113
165
|
* that persists conversations (see session-store.ts) should use this
|
|
114
166
|
* instead of diffing `history` by length against what it loaded: that
|
|
115
167
|
* only works if `history` only ever grows, which stops being true the
|
|
@@ -132,8 +184,62 @@ export interface RunAgentResult {
|
|
|
132
184
|
* model requested in the same turn, even one already approved, never
|
|
133
185
|
* runs either (see the loop's own body); it gets a "skipped", not a
|
|
134
186
|
* "denied", tool_result, since it was never evaluated against its own
|
|
135
|
-
* rule.
|
|
136
|
-
|
|
187
|
+
* rule.
|
|
188
|
+
*
|
|
189
|
+
* 'pending_approval' / 'pending_question': at least one requested tool
|
|
190
|
+
* call is durably pending a DurableApprover decision, or at least one
|
|
191
|
+
* `system_ask_user` call is durably pending a DurableQuestionHandler
|
|
192
|
+
* answer (see HUMAN_IN_THE_LOOP.md) — same "the whole batch stops here"
|
|
193
|
+
* shape as 'denied', except this isn't a rejection: any already-run
|
|
194
|
+
* sibling call in the same batch still ran (see the loop's own
|
|
195
|
+
* bucket-then-execute), captured in `pending.resultsSoFar`, and the
|
|
196
|
+
* turn resumes later via resumeAgent() once every pendingId in
|
|
197
|
+
* `pending.outstanding` is resolved — not by sending another message
|
|
198
|
+
* the way 'denied' recovers. A batch can mix both kinds (a gated tool
|
|
199
|
+
* call and an `system_ask_user` call in the same model response) — both
|
|
200
|
+
* land in the same `pending.outstanding`, since the one dangling
|
|
201
|
+
* assistant tool_use message can only ever get one completing
|
|
202
|
+
* tool_result message; `stopReason` reports 'pending_approval' for that
|
|
203
|
+
* mixed case too (only a batch with *no* approval items at all reports
|
|
204
|
+
* 'pending_question'). `newMessages` ends at the dangling assistant
|
|
205
|
+
* tool_use message, same crash-recovery shape session-store.ts's own
|
|
206
|
+
* hasUnresolvedToolCall already detects — entered on purpose here,
|
|
207
|
+
* not by accident. runAgent() does no I/O (see this module's own
|
|
208
|
+
* header comment): turning `pending` into a durable TurnCheckpoint
|
|
209
|
+
* (core/durable-approvals.ts) is the caller's job. */
|
|
210
|
+
stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question';
|
|
211
|
+
/** Set only when stopReason is 'pending_approval' or 'pending_question'. */
|
|
212
|
+
pending?: {
|
|
213
|
+
/** tool_result blocks already computed this batch — safely
|
|
214
|
+
* auto-allowed calls that ran immediately despite a gated/asked
|
|
215
|
+
* sibling still being outstanding. Not yet pushed into `newMessages`/
|
|
216
|
+
* `history`: incomplete until every outstanding item below is
|
|
217
|
+
* resolved too, and a real model API expects one complete
|
|
218
|
+
* tool_result message per assistant tool_use message, not a partial
|
|
219
|
+
* one now and a follow-up later. */
|
|
220
|
+
resultsSoFar: ModelContentBlock[];
|
|
221
|
+
/** One entry per tool call still awaiting a durable decision, keyed
|
|
222
|
+
* by the pendingId its DurableApprover/DurableQuestionHandler handed
|
|
223
|
+
* out — what an incoming resolution actually names. `kind`
|
|
224
|
+
* distinguishes which of the two this is — for 'question', `args` is
|
|
225
|
+
* `{ question, options }` and `reason` is the question text itself
|
|
226
|
+
* (there's no separate ActAuth "reason" for a question the way there
|
|
227
|
+
* is for a gated tool call's decision). */
|
|
228
|
+
outstanding: PendingItem[];
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** One requested call still awaiting a durable decision — either a gated
|
|
232
|
+
* tool call (`kind: 'approval'`) or a `system_ask_user` call (`kind:
|
|
233
|
+
* 'question'`), unified into one array so a batch mixing both shares
|
|
234
|
+
* exactly one checkpoint (see RunAgentResult.stopReason's own doc
|
|
235
|
+
* comment for why that has to be true). */
|
|
236
|
+
export interface PendingItem {
|
|
237
|
+
kind: 'approval' | 'question';
|
|
238
|
+
toolUseId: string;
|
|
239
|
+
tool: string;
|
|
240
|
+
args: Record<string, unknown>;
|
|
241
|
+
pendingId: string;
|
|
242
|
+
reason: string;
|
|
137
243
|
}
|
|
138
244
|
/** Resolves AgentConfig.rules the same way skillsDirs resolves its own
|
|
139
245
|
* default: an inline array is used as-is (full 3-segment scopePattern,
|
|
@@ -180,3 +286,25 @@ export declare function loadDefaultTools(config: AgentConfig): Promise<ToolDefin
|
|
|
180
286
|
* elsewhere could. */
|
|
181
287
|
export declare function loadSubagentAsTools(config: AgentConfig): Promise<ToolDefinition[]>;
|
|
182
288
|
export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
|
|
289
|
+
/** Continues a turn durably paused on RunAgentResult.stopReason ===
|
|
290
|
+
* 'pending_approval' — see HUMAN_IN_THE_LOOP.md for the full design.
|
|
291
|
+
* `history` is durable session history including the dangling assistant
|
|
292
|
+
* tool_use message the pause left behind (exactly what a caller's
|
|
293
|
+
* SessionStore.getHistory/withSession already returns — this needs no
|
|
294
|
+
* special resumed-session handling, since that message is real, already
|
|
295
|
+
* on disk). `resolution` is the *complete* set of tool_result blocks
|
|
296
|
+
* answering that message — every entry from a resolved TurnCheckpoint's
|
|
297
|
+
* own resultsSoFar, once its outstanding is empty; assembling that is the
|
|
298
|
+
* caller's job (core/durable-approvals.ts's own CheckpointStore only
|
|
299
|
+
* stores it, it doesn't execute tools or know when a batch is complete —
|
|
300
|
+
* see that module's own doc comment), not something this function
|
|
301
|
+
* derives on its own.
|
|
302
|
+
*
|
|
303
|
+
* Pushes `resolution` as the completing message and picks the loop back
|
|
304
|
+
* up from there — a fresh model call, now with the full picture, exactly
|
|
305
|
+
* like a synchronous 'ask' would have continued if it hadn't needed to
|
|
306
|
+
* pause at all. Can itself return with stopReason 'pending_approval'
|
|
307
|
+
* again (the model's next round of tool calls hits another durably-gated
|
|
308
|
+
* one), 'denied', 'max_turns', or a genuine finish — runLoop handles all
|
|
309
|
+
* of those uniformly regardless of how this turn started. */
|
|
310
|
+
export declare function resumeAgent(config: AgentConfig, modelCall: ModelCall, history: Message[], resolution: ModelContentBlock[], options?: RunAgentOptions): Promise<RunAgentResult>;
|