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.
Files changed (39) hide show
  1. package/PROTOCOL.md +245 -0
  2. package/README.md +53 -10
  3. package/dist/core/agent-config.d.ts +282 -3
  4. package/dist/core/budget.d.ts +42 -0
  5. package/dist/core/budget.js +39 -0
  6. package/dist/core/compaction.d.ts +43 -0
  7. package/dist/core/compaction.js +70 -0
  8. package/dist/core/http-notifier.d.ts +10 -0
  9. package/dist/core/http-notifier.js +116 -0
  10. package/dist/core/http-notify-triggers/database.d.ts +19 -0
  11. package/dist/core/http-notify-triggers/database.js +53 -0
  12. package/dist/core/http-notify-triggers/email.d.ts +44 -0
  13. package/dist/core/http-notify-triggers/email.js +126 -0
  14. package/dist/core/http-notify-triggers/lark.d.ts +28 -0
  15. package/dist/core/http-notify-triggers/lark.js +168 -0
  16. package/dist/core/http-notify-triggers/redis.d.ts +33 -0
  17. package/dist/core/http-notify-triggers/redis.js +54 -0
  18. package/dist/core/http-notify-triggers/slack.d.ts +21 -0
  19. package/dist/core/http-notify-triggers/slack.js +91 -0
  20. package/dist/core/http-notify-triggers/webhook.d.ts +27 -0
  21. package/dist/core/http-notify-triggers/webhook.js +94 -0
  22. package/dist/core/loop-events.d.ts +60 -29
  23. package/dist/core/model-calls/openai-model-call.js +33 -17
  24. package/dist/core/recovery.d.ts +49 -0
  25. package/dist/core/recovery.js +124 -0
  26. package/dist/core/run-agent.d.ts +143 -15
  27. package/dist/core/run-agent.js +321 -66
  28. package/dist/core/session-store.d.ts +2 -1
  29. package/dist/core/session-store.js +6 -4
  30. package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +25 -0
  31. package/dist/core/system-tools/ask_user.d.ts +97 -31
  32. package/dist/core/system-tools/ask_user.js +157 -58
  33. package/dist/core/system-tools/index.d.ts +1 -1
  34. package/dist/core/system-tools/index.js +1 -1
  35. package/dist/core/vector-index.js +1 -1
  36. package/dist/package.json +6 -0
  37. package/package.json +16 -6
  38. package/protocol/loop-event.schema.json +257 -0
  39. /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 — reflowkit's
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
- return [
39
- {
40
- role: 'assistant',
41
- // Required unless tool_calls is set — an empty string here (no
42
- // text, only tool calls) is rejected by the API, so this must be
43
- // null, not ''.
44
- content: text || null,
45
- tool_calls: toolUseBlocks.length
46
- ? toolUseBlocks.map((block) => ({
47
- id: block.id,
48
- type: 'function',
49
- function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) },
50
- }))
51
- : undefined,
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
+ }
@@ -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
- * ContextClip's `{role, content: string}`: `content` can also be a
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
- /** 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. */
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 ContextClip recovery reshaped `history` mid-turn. A caller
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
- stopReason?: 'max_turns' | 'denied';
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>;