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.
- package/PROTOCOL.md +3 -3
- package/README.md +45 -10
- package/dist/core/agent-config.d.ts +291 -8
- 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 +30 -6
- package/dist/core/model-calls/openai-model-call.js +32 -16
- package/dist/core/recovery.d.ts +49 -0
- package/dist/core/recovery.js +124 -0
- package/dist/core/run-agent.d.ts +141 -13
- package/dist/core/run-agent.js +316 -57
- package/dist/core/session-store.d.ts +1 -0
- 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/package.json +6 -0
- package/package.json +14 -4
- package/protocol/loop-event.schema.json +2 -2
- /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
|
-
/**
|
|
147
|
-
* stopReasons (max_turns/denied)
|
|
148
|
-
* treat as "nothing more is coming
|
|
149
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|