loopengine 0.0.21 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/PROTOCOL.md +3 -3
  2. package/README.md +45 -10
  3. package/dist/core/agent-config.d.ts +291 -8
  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 +30 -6
  23. package/dist/core/model-calls/openai-model-call.js +32 -16
  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 +141 -13
  27. package/dist/core/run-agent.js +316 -57
  28. package/dist/core/session-store.d.ts +1 -0
  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/package.json +6 -0
  36. package/package.json +14 -4
  37. package/protocol/loop-event.schema.json +2 -2
  38. /package/{core → dist/core}/system-skills/composio-large-outputs/SKILL.md +0 -0
@@ -0,0 +1,21 @@
1
+ import type { DurableApprover, Scope } from 'actauth';
2
+ import type { DurableQuestionHandler } from '../agent-config.js';
3
+ export declare class SlackNotifier implements DurableApprover, DurableQuestionHandler {
4
+ private readonly botToken;
5
+ private readonly channelId;
6
+ constructor(options: {
7
+ botToken: string;
8
+ channelId: string;
9
+ });
10
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
11
+ pendingId: string;
12
+ };
13
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
14
+ pendingId: string;
15
+ };
16
+ /** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
17
+ * lifecycle sender for `channel: 'slack'` calls this directly with no
18
+ * `blocks` — a plain announcement, since there's nothing to resolve and
19
+ * so nothing to attach buttons to. */
20
+ postMessage(text: string, blocks?: unknown[]): void;
21
+ }
@@ -0,0 +1,91 @@
1
+ // The *sending* half of a Slack-backed DurableApprover/DurableQuestionHandler,
2
+ // real, maintained core/ code so AgentConfig.httpNotifier
3
+ // (core/http-notifier.ts) can construct one directly for `channel:
4
+ // 'slack'`, the same way it already constructs actauth's own
5
+ // WebhookApprover for `channel: 'webhook'`. Not "DurableSlackNotifier":
6
+ // unlike the Web/Webhook pair, there's no live "SlackNotifier" this name
7
+ // could collide with (Slack's own *live* approver, actauth's
8
+ // `SlackChatApprover`, has an entirely different name already) — durable is
9
+ // just what a Slack-backed notifier defaults to being, so the name
10
+ // doesn't need to say so.
11
+ //
12
+ // The *receiving* side — verifying a Slack Interactivity payload really
13
+ // came from Slack, then dispatching approve/deny/answer back into
14
+ // loopengine's own resolve/answer routes — deliberately stays out of
15
+ // core and out of this class: that's an HTTP endpoint only the host can
16
+ // deploy (it needs a route, a public URL, Slack app configuration), the
17
+ // same "sending built for you, receiving you deploy" split every other
18
+ // durable channel in this repo has. See
19
+ // examples/notifier-handler/slack.ts's own `SlackNotifierHandler` for
20
+ // that half.
21
+ //
22
+ // One class for both concerns, not two: nothing about what a Slack
23
+ // integration needs to send is approval-specific or question-specific,
24
+ // it's Slack-specific (the bot token, the channel, the chat.postMessage
25
+ // call shape).
26
+ import { randomUUID } from 'node:crypto';
27
+ export class SlackNotifier {
28
+ botToken;
29
+ channelId;
30
+ constructor(options) {
31
+ this.botToken = options.botToken;
32
+ this.channelId = options.channelId;
33
+ }
34
+ requestDurableApproval(tool, args, scope, reason) {
35
+ const pendingId = randomUUID();
36
+ this.postMessage(`Approval requested: ${tool} on ${scope.tenant}/${scope.environment}/${scope.agent}`, [
37
+ {
38
+ type: 'section',
39
+ text: {
40
+ type: 'mrkdwn',
41
+ text: `*Approval requested*\n*tool:* ${tool}\n*args:* \`${JSON.stringify(args)}\`\n*scope:* ${scope.tenant}/${scope.environment}/${scope.agent}\n*reason:* ${reason}`,
42
+ },
43
+ },
44
+ {
45
+ type: 'actions',
46
+ elements: [
47
+ { type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary', action_id: 'approve', value: pendingId },
48
+ { type: 'button', text: { type: 'plain_text', text: 'Deny' }, style: 'danger', action_id: 'deny', value: pendingId },
49
+ ],
50
+ },
51
+ ]);
52
+ return { pendingId };
53
+ }
54
+ notifyPendingQuestion(question, options, agent, sessionId) {
55
+ const pendingId = randomUUID();
56
+ const optionButtons = (options ?? []).map((option) => ({
57
+ type: 'button',
58
+ text: { type: 'plain_text', text: option },
59
+ value: JSON.stringify({ pendingId, answer: option }),
60
+ action_id: 'answer_option',
61
+ }));
62
+ this.postMessage(`Question from ${agent}: ${question}`, [
63
+ { type: 'section', text: { type: 'mrkdwn', text: `*Question from ${agent}${sessionId ? ` (session ${sessionId})` : ''}:*\n${question}` } },
64
+ { type: 'actions', elements: [...optionButtons, { type: 'button', text: { type: 'plain_text', text: 'Other…' }, action_id: 'open_answer_modal', value: pendingId }] },
65
+ ]);
66
+ return { pendingId };
67
+ }
68
+ /** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
69
+ * lifecycle sender for `channel: 'slack'` calls this directly with no
70
+ * `blocks` — a plain announcement, since there's nothing to resolve and
71
+ * so nothing to attach buttons to. */
72
+ postMessage(text, blocks) {
73
+ // Not awaited — both requestDurableApproval/notifyPendingQuestion
74
+ // above return immediately, by contract (see DurableApprover's own
75
+ // doc comment); a delivery failure here has no synchronous way to
76
+ // surface to either caller, so it's logged instead of thrown.
77
+ fetch('https://slack.com/api/chat.postMessage', {
78
+ method: 'POST',
79
+ headers: { Authorization: `Bearer ${this.botToken}`, 'content-type': 'application/json; charset=utf-8' },
80
+ body: JSON.stringify({ channel: this.channelId, text, ...(blocks ? { blocks } : {}) }),
81
+ })
82
+ .then((res) => res.json())
83
+ .then((body) => {
84
+ if (!body.ok)
85
+ console.error('[loopengine] SlackNotifier: chat.postMessage failed:', body.error);
86
+ })
87
+ .catch((err) => {
88
+ console.error('[loopengine] SlackNotifier: chat.postMessage failed:', err);
89
+ });
90
+ }
91
+ }
@@ -0,0 +1,27 @@
1
+ import type { DurableApprover, Scope } from 'actauth';
2
+ import type { DurableQuestionHandler } from '../agent-config.js';
3
+ export declare class WebhookNotifier implements DurableApprover, DurableQuestionHandler {
4
+ private readonly webhookUrl;
5
+ private readonly signingSecret;
6
+ constructor(options: {
7
+ webhookUrl: string;
8
+ signingSecret: string;
9
+ });
10
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
11
+ pendingId: string;
12
+ };
13
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
14
+ pendingId: string;
15
+ };
16
+ /** Shared by both methods above — the one place either used to
17
+ * independently build the same signed fetch() call. Not awaited by its
18
+ * own callers, same fire-and-forget contract each had on its own: both
19
+ * requestDurableApproval/notifyPendingQuestion return immediately, by
20
+ * contract (see DurableApprover/DurableQuestionHandler's own doc
21
+ * comments), so a delivery failure here has no synchronous way to
22
+ * surface to the caller — logged instead of thrown, since throwing
23
+ * would blow up a decision that has already, correctly, been recorded
24
+ * as pending. */
25
+ private post;
26
+ }
27
+ export declare function postLifecycleWebhook(webhookUrl: string, webhookSecret: string, event: 'run_start' | 'run_finish', context: Record<string, unknown>): void;
@@ -0,0 +1,94 @@
1
+ // The generic-webhook channel's sending side, one class for both
2
+ // concerns — same "it's channel-specific, not concern-specific" shape
3
+ // slack.ts/lark.ts/email.ts already have, not the "two already-shipped
4
+ // classes from two separate packages" split this file used to be (see
5
+ // git history): approval-sending used to be actauth's own
6
+ // `WebhookApprover`, question-sending loopengine's own
7
+ // `WebhookQuestionHandler` (`core/system-tools/ask_user.ts`) — genuinely
8
+ // separate classes, since `actauth`'s `Gate` has no concept of
9
+ // `system_ask_user` questions at all (see HUMAN_IN_THE_LOOP.md's
10
+ // "Durable questions" section). Nothing about *sending* a signed webhook
11
+ // POST is actauth-specific, though, once you stop assuming the approval
12
+ // half has to live in actauth's own package — both halves are the exact
13
+ // same HMAC-SHA256-over-the-raw-body scheme, just under different
14
+ // headers, so merging them removes real duplication (one class, one
15
+ // constructor, one place either concern's own webhook target/secret is
16
+ // held) rather than just looking simpler on paper.
17
+ //
18
+ // `actauth`'s own `WebhookApprover` still exists and is unaffected —
19
+ // this is loopengine's own implementation of the same `DurableApprover`
20
+ // interface, not a fork of actauth's class; a standalone actauth
21
+ // consumer (no loopengine involved) still has and should use actauth's
22
+ // own. loopengine no longer imports it anywhere, since everywhere it
23
+ // used to (this file, and adapters/http.ts's own deployment-wide
24
+ // default) now constructs one `WebhookNotifier` instead.
25
+ //
26
+ // The signed payloads below are wire-compatible with what actauth's
27
+ // `WebhookApprover`/loopengine's former `WebhookQuestionHandler` already
28
+ // sent — same fields, same `X-Actauth-Signature`/`X-Askuser-Signature`
29
+ // headers — so an existing receiving-side deployment (this repo's own
30
+ // `examples/notifier-handler/webhook.ts`, or a host's own) needs no
31
+ // changes; only where the bytes come from moved.
32
+ //
33
+ // `postLifecycleWebhook` below is the third, genuinely new piece:
34
+ // `onRunStart`/`onRunFinish` have no shipped class on either side, for
35
+ // any channel (see this repo's own history — agents/customer-service/index.ts
36
+ // used to hand-write this exact function before `AgentConfig.httpNotifier`
37
+ // existed). Same signing scheme, under its own `X-Lifecycle-Signature`
38
+ // header so a receiver can tell a lifecycle event apart from a
39
+ // pending-approval/pending-question one hitting the same URL (see
40
+ // examples/notifier-handler/webhook.ts's own `verifyWebhookNotifier` for
41
+ // the receiving side of all three).
42
+ import { createHmac, randomUUID } from 'node:crypto';
43
+ export class WebhookNotifier {
44
+ webhookUrl;
45
+ signingSecret;
46
+ constructor(options) {
47
+ this.webhookUrl = options.webhookUrl;
48
+ this.signingSecret = options.signingSecret;
49
+ }
50
+ requestDurableApproval(tool, args, scope, reason) {
51
+ const pendingId = randomUUID();
52
+ const requestedAt = new Date().toISOString();
53
+ const body = JSON.stringify({ pendingId, tool, args, scope, reason, requestedAt });
54
+ this.post(body, 'X-Actauth-Signature', pendingId);
55
+ return { pendingId };
56
+ }
57
+ notifyPendingQuestion(question, options, agent, sessionId) {
58
+ const pendingId = randomUUID();
59
+ const requestedAt = new Date().toISOString();
60
+ const body = JSON.stringify({ pendingId, question, options, agent, sessionId, requestedAt });
61
+ this.post(body, 'X-Askuser-Signature', pendingId);
62
+ return { pendingId };
63
+ }
64
+ /** Shared by both methods above — the one place either used to
65
+ * independently build the same signed fetch() call. Not awaited by its
66
+ * own callers, same fire-and-forget contract each had on its own: both
67
+ * requestDurableApproval/notifyPendingQuestion return immediately, by
68
+ * contract (see DurableApprover/DurableQuestionHandler's own doc
69
+ * comments), so a delivery failure here has no synchronous way to
70
+ * surface to the caller — logged instead of thrown, since throwing
71
+ * would blow up a decision that has already, correctly, been recorded
72
+ * as pending. */
73
+ post(body, signatureHeader, pendingId) {
74
+ const signature = `sha256=${createHmac('sha256', this.signingSecret).update(body).digest('hex')}`;
75
+ fetch(this.webhookUrl, {
76
+ method: 'POST',
77
+ headers: { 'content-type': 'application/json', [signatureHeader]: signature },
78
+ body,
79
+ }).catch((err) => {
80
+ console.error(`[loopengine] WebhookNotifier: webhook delivery failed for pendingId '${pendingId}':`, err);
81
+ });
82
+ }
83
+ }
84
+ export function postLifecycleWebhook(webhookUrl, webhookSecret, event, context) {
85
+ const payload = JSON.stringify({ event, ...context, occurredAt: new Date().toISOString() });
86
+ const signature = `sha256=${createHmac('sha256', webhookSecret).update(payload).digest('hex')}`;
87
+ fetch(webhookUrl, {
88
+ method: 'POST',
89
+ headers: { 'content-type': 'application/json', 'X-Lifecycle-Signature': signature },
90
+ body: payload,
91
+ }).catch((err) => {
92
+ console.error(`[loopengine] httpNotifier ${event} webhook delivery failed:`, err);
93
+ });
94
+ }
@@ -109,6 +109,26 @@ export interface LoopDeniedEvent {
109
109
  type: 'loop:denied';
110
110
  deniedTools: string[];
111
111
  }
112
+ /** At least one requested tool call is durably pending a DurableApprover
113
+ * decision — the turn stops right there too, but unlike loop:denied this
114
+ * isn't a rejection: any already-approved sibling call in the same batch
115
+ * still ran (see run-agent.ts's own bucket-then-execute), and the turn
116
+ * resumes (via resumeAgent) once every pendingId here is resolved. See
117
+ * HUMAN_IN_THE_LOOP.md for the full design. */
118
+ export interface PendingApprovalEvent {
119
+ type: 'loop:pending_approval';
120
+ pendingIds: string[];
121
+ }
122
+ /** The question-side sibling of PendingApprovalEvent above — at least one
123
+ * `system_ask_user` call is durably pending a DurableQuestionHandler
124
+ * (see HUMAN_IN_THE_LOOP.md's "Durable questions" section), fired only
125
+ * when nothing else in the same batch is a pending *approval* (a mixed
126
+ * batch reports PendingApprovalEvent instead — see
127
+ * RunAgentResult.stopReason's own doc comment for why). */
128
+ export interface PendingQuestionEvent {
129
+ type: 'loop:pending_question';
130
+ pendingIds: string[];
131
+ }
112
132
  /** The loop's own final answer — the model produced text with no more
113
133
  * tool_use blocks. Distinct from the adapter-level 'done' below: this
114
134
  * only ever means a genuine finish, never a synthetic max_turns/denied
@@ -120,7 +140,7 @@ export interface LoopDoneEvent {
120
140
  /** Every event runAgent() itself can emit via RunAgentOptions.onEvent —
121
141
  * not the adapter-synthesized ones below, which no caller of runAgent()
122
142
  * itself ever produces. */
123
- export type RunAgentLoopEvent = BudgetCheckEvent | PromptCompactionEvent | AssistantTextEvent | ActauthDecisionEvent | ToolStartedEvent | ToolResultEvent | ToolLaneResultEvent | SkillLoadEvent | LoopSkippedEvent | LoopMaxTurnsEvent | LoopDeniedEvent | LoopDoneEvent;
143
+ export type RunAgentLoopEvent = BudgetCheckEvent | PromptCompactionEvent | AssistantTextEvent | ActauthDecisionEvent | ToolStartedEvent | ToolResultEvent | ToolLaneResultEvent | SkillLoadEvent | LoopSkippedEvent | LoopMaxTurnsEvent | LoopDeniedEvent | PendingApprovalEvent | PendingQuestionEvent | LoopDoneEvent;
124
144
  /** adapters/http.ts echoing back which session id this turn used — first
125
145
  * event of every streamed turn, so a caller that omitted sessionId can
126
146
  * capture what got generated. Not something runAgent() itself knows
@@ -143,14 +163,18 @@ export type ApprovalPendingEvent = {
143
163
  export type QuestionPendingEvent = {
144
164
  type: 'question:pending';
145
165
  } & PendingQuestion;
146
- /** The turn is over — covers a genuine finish and both synthetic
147
- * stopReasons (max_turns/denied) alike, the one event every caller can
148
- * treat as "nothing more is coming" regardless of which of the three it
149
- * was (see RunAgentResult.stopReason's own doc comment). */
166
+ /** This turn/request is over — covers a genuine finish and all four
167
+ * synthetic stopReasons (max_turns/denied/pending_approval/pending_question)
168
+ * alike, the one event every caller can treat as "nothing more is coming
169
+ * on this response" regardless of which it was (see
170
+ * RunAgentResult.stopReason's own doc comment). pending_approval/
171
+ * pending_question, like denied, don't mean the conversation itself is
172
+ * over — only this turn; it resumes later via a separate resolve call,
173
+ * not by continuing to read this same response. */
150
174
  export interface DoneEvent {
151
175
  type: 'done';
152
176
  text: string;
153
- stopReason?: 'max_turns' | 'denied';
177
+ stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question';
154
178
  }
155
179
  /** Something failed after the response was already committed (SSE
156
180
  * headers sent, or a background turn already running) — surfaced in-band
@@ -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
+ }