@ixo/oracle-runtime-workers 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -22,8 +22,36 @@ export interface PageContextMiddlewareOptions {
|
|
|
22
22
|
logger?: Logger;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Longest page title rendered (characters); longer ones are cut. */
|
|
26
|
+
export const PAGE_TITLE_MAX_CHARS = 120;
|
|
27
|
+
/** Longest room id rendered; a real Matrix room id is far shorter. */
|
|
28
|
+
const ROOM_ID_MAX_CHARS = 255;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Text set by other people (a page title any room member can change, a room
|
|
32
|
+
* id from the request) as inert single-line text: line breaks, control and
|
|
33
|
+
* format characters become spaces, runs of whitespace one space, the
|
|
34
|
+
* markdown and markup characters that open code, emphasis, headings,
|
|
35
|
+
* quotes, tables, links or tags (`` ` * # > < [ ] | ``) are dropped, the
|
|
36
|
+
* length is capped. What remains cannot open a section, a list or a tag in
|
|
37
|
+
* the system prompt.
|
|
38
|
+
*/
|
|
39
|
+
export function inertInline(text: string, maxChars: number): string {
|
|
40
|
+
const flat = text
|
|
41
|
+
.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ')
|
|
42
|
+
.replace(/[`*#><|[\]]/g, '')
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim();
|
|
45
|
+
return flat.length <= maxChars
|
|
46
|
+
? flat
|
|
47
|
+
: `${flat.slice(0, maxChars).trimEnd()}…`;
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
function formatLabel(title: string | undefined, roomId: string): string {
|
|
26
|
-
|
|
51
|
+
const id = inertInline(roomId, ROOM_ID_MAX_CHARS);
|
|
52
|
+
const name = title ? inertInline(title, PAGE_TITLE_MAX_CHARS) : '';
|
|
53
|
+
// Quoted as a value (JSON string rules): a quote in the title cannot end it.
|
|
54
|
+
return name ? `${JSON.stringify(name)} (${id})` : id;
|
|
27
55
|
}
|
|
28
56
|
|
|
29
57
|
/**
|
|
@@ -31,12 +59,28 @@ function formatLabel(title: string | undefined, roomId: string): string {
|
|
|
31
59
|
* the agent is editing a page (`state.editorRoomId` is set). When the user
|
|
32
60
|
* switches pages mid-conversation the block flags it explicitly so the
|
|
33
61
|
* agent re-reads the current page before editing.
|
|
62
|
+
*
|
|
63
|
+
* A page's title is looked up once per room for the life of the middleware
|
|
64
|
+
* (one turn: the main agent is built per turn), not on every model step.
|
|
34
65
|
*/
|
|
35
66
|
export const createPageContextMiddleware = (
|
|
36
67
|
options: PageContextMiddlewareOptions,
|
|
37
68
|
): AgentMiddleware => {
|
|
38
69
|
const logger = options.logger ?? NOOP_LOGGER;
|
|
39
|
-
const
|
|
70
|
+
const titles = new Map<string, Promise<string | undefined>>();
|
|
71
|
+
const getRoomTitle = (roomId: string): Promise<string | undefined> => {
|
|
72
|
+
let title = titles.get(roomId);
|
|
73
|
+
if (!title) {
|
|
74
|
+
title = options.getRoomTitle(roomId).catch((error: unknown) => {
|
|
75
|
+
logger.warn(
|
|
76
|
+
`[PageContextMiddleware] title lookup failed for ${roomId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
77
|
+
);
|
|
78
|
+
return undefined;
|
|
79
|
+
});
|
|
80
|
+
titles.set(roomId, title);
|
|
81
|
+
}
|
|
82
|
+
return title;
|
|
83
|
+
};
|
|
40
84
|
|
|
41
85
|
return createMiddleware({
|
|
42
86
|
name: 'PageContextMiddleware',
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangChain's agent loop exits when the LAST tool result of a step comes
|
|
3
|
+
* from a return-direct tool. A model that calls one alongside other tools
|
|
4
|
+
* would then never see those other results. This middleware moves the
|
|
5
|
+
* return-direct call to the front of such a step, so the loop continues and
|
|
6
|
+
* the model answers with every result in view.
|
|
7
|
+
*/
|
|
8
|
+
import { AIMessage, isAIMessage } from '@langchain/core/messages';
|
|
9
|
+
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
10
|
+
|
|
11
|
+
export function createReturnDirectFirstMiddleware(
|
|
12
|
+
toolNames: ReadonlySet<string>,
|
|
13
|
+
): AgentMiddleware {
|
|
14
|
+
return createMiddleware({
|
|
15
|
+
name: 'ReturnDirectFirstMiddleware',
|
|
16
|
+
afterModel: (state) => {
|
|
17
|
+
const last = state.messages.at(-1);
|
|
18
|
+
if (!last || !isAIMessage(last)) return undefined;
|
|
19
|
+
const calls = last.tool_calls ?? [];
|
|
20
|
+
const lastCall = calls.at(-1);
|
|
21
|
+
// Only a return-direct call in last place ends the loop early, and
|
|
22
|
+
// only when some other tool's result would go unanswered.
|
|
23
|
+
if (!lastCall || !toolNames.has(lastCall.name)) return undefined;
|
|
24
|
+
if (calls.every((c) => toolNames.has(c.name))) return undefined;
|
|
25
|
+
const direct = calls.filter((c) => toolNames.has(c.name));
|
|
26
|
+
return {
|
|
27
|
+
messages: [
|
|
28
|
+
new AIMessage({
|
|
29
|
+
id: last.id,
|
|
30
|
+
name: last.name,
|
|
31
|
+
content: last.content,
|
|
32
|
+
additional_kwargs: last.additional_kwargs,
|
|
33
|
+
response_metadata: last.response_metadata,
|
|
34
|
+
...(last.usage_metadata
|
|
35
|
+
? { usage_metadata: last.usage_metadata }
|
|
36
|
+
: {}),
|
|
37
|
+
invalid_tool_calls: last.invalid_tool_calls ?? [],
|
|
38
|
+
tool_calls: [
|
|
39
|
+
...direct,
|
|
40
|
+
...calls.filter((c) => !toolNames.has(c.name)),
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
AIMessage,
|
|
4
|
+
HumanMessage,
|
|
5
|
+
ToolMessage,
|
|
6
|
+
type BaseMessage,
|
|
7
|
+
} from '@langchain/core/messages';
|
|
3
8
|
import { type AgentMiddleware, summarizationMiddleware } from 'langchain';
|
|
4
9
|
import type { Logger } from '../../plugin-api/types';
|
|
10
|
+
import type { TurnBudget } from '../turn-budget';
|
|
11
|
+
import {
|
|
12
|
+
carriedCallsOfTurn,
|
|
13
|
+
isSummarizationMessage,
|
|
14
|
+
SUMMARY_PREFIX,
|
|
15
|
+
turnCarryKwargs,
|
|
16
|
+
turnStart,
|
|
17
|
+
} from './turn-boundary';
|
|
5
18
|
|
|
6
19
|
/**
|
|
7
20
|
* IXO-flavoured prompt instructing the summarizer to preserve identifiers
|
|
@@ -48,7 +61,7 @@ Messages to summarize:
|
|
|
48
61
|
{messages}
|
|
49
62
|
</messages>`;
|
|
50
63
|
|
|
51
|
-
export
|
|
64
|
+
export { isSummarizationMessage, SUMMARY_PREFIX };
|
|
52
65
|
|
|
53
66
|
const DEFAULT_TRIGGER_MESSAGES = 20;
|
|
54
67
|
const DEFAULT_TRIGGER_TOKENS = 40_000;
|
|
@@ -73,6 +86,12 @@ export interface SummarizationMiddlewareOptions {
|
|
|
73
86
|
keepMessages?: number;
|
|
74
87
|
/** What the summarizer itself may read (tokens); default LangChain's. */
|
|
75
88
|
summaryInputTokens?: number;
|
|
89
|
+
/**
|
|
90
|
+
* The turn's budget: a summary is not started after the deadline or the
|
|
91
|
+
* abort, and its model call is charged like any other (the metered
|
|
92
|
+
* adapter does that; the check here is the cheap early exit).
|
|
93
|
+
*/
|
|
94
|
+
budget?: TurnBudget;
|
|
76
95
|
logger?: Pick<Logger, 'warn' | 'log'>;
|
|
77
96
|
}
|
|
78
97
|
|
|
@@ -94,21 +113,101 @@ export function isFailedSummary(message: BaseMessage): boolean {
|
|
|
94
113
|
return FAILED_SUMMARY.test(body);
|
|
95
114
|
}
|
|
96
115
|
|
|
116
|
+
interface ApproximateChars {
|
|
117
|
+
content: unknown;
|
|
118
|
+
toolCalls: unknown;
|
|
119
|
+
toolCallId: unknown;
|
|
120
|
+
chars: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const approximateChars = new WeakMap<BaseMessage, ApproximateChars>();
|
|
124
|
+
|
|
97
125
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
126
|
+
* The characters LangChain's `countTokensApproximately` counts for one
|
|
127
|
+
* message (its text, an AI message's tool calls as JSON, a tool message's
|
|
128
|
+
* call id), memoised per message while those values are unchanged.
|
|
101
129
|
*/
|
|
102
|
-
|
|
103
|
-
|
|
130
|
+
function charsOf(message: BaseMessage): number {
|
|
131
|
+
const toolCalls = AIMessage.isInstance(message)
|
|
132
|
+
? message.tool_calls
|
|
133
|
+
: undefined;
|
|
134
|
+
const toolCallId = ToolMessage.isInstance(message)
|
|
135
|
+
? message.tool_call_id
|
|
136
|
+
: undefined;
|
|
137
|
+
const cached = approximateChars.get(message);
|
|
138
|
+
if (
|
|
139
|
+
cached &&
|
|
140
|
+
cached.content === message.content &&
|
|
141
|
+
cached.toolCalls === toolCalls &&
|
|
142
|
+
cached.toolCallId === toolCallId
|
|
143
|
+
)
|
|
144
|
+
return cached.chars;
|
|
104
145
|
const { content } = message;
|
|
105
|
-
|
|
146
|
+
let text: string;
|
|
147
|
+
if (typeof content === 'string') text = content;
|
|
148
|
+
else if (Array.isArray(content))
|
|
149
|
+
text = content
|
|
150
|
+
.map((item: unknown) => {
|
|
151
|
+
if (typeof item === 'string') return item;
|
|
152
|
+
if (
|
|
153
|
+
item &&
|
|
154
|
+
typeof item === 'object' &&
|
|
155
|
+
'type' in item &&
|
|
156
|
+
item.type === 'text' &&
|
|
157
|
+
'text' in item
|
|
158
|
+
)
|
|
159
|
+
return item.text;
|
|
160
|
+
return '';
|
|
161
|
+
})
|
|
162
|
+
.join('');
|
|
163
|
+
else text = '';
|
|
164
|
+
if (Array.isArray(toolCalls) && toolCalls.length > 0)
|
|
165
|
+
text += JSON.stringify(toolCalls);
|
|
166
|
+
if (ToolMessage.isInstance(message)) text += toolCallId ?? '';
|
|
167
|
+
approximateChars.set(message, {
|
|
168
|
+
content,
|
|
169
|
+
toolCalls,
|
|
170
|
+
toolCallId,
|
|
171
|
+
chars: text.length,
|
|
172
|
+
});
|
|
173
|
+
return text.length;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* LangChain's `countTokensApproximately` over messages (chars/4 of the
|
|
178
|
+
* total, rounded up) with the per-message work memoised: the summarizer
|
|
179
|
+
* counts the whole history before every model step, and again while it
|
|
180
|
+
* trims what it reads.
|
|
181
|
+
*/
|
|
182
|
+
export function countMessageTokensApproximately(
|
|
183
|
+
messages: readonly BaseMessage[],
|
|
184
|
+
): number {
|
|
185
|
+
let chars = 0;
|
|
186
|
+
for (const message of messages) chars += charsOf(message);
|
|
187
|
+
return Math.ceil(chars / 4);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The turn-opening message of `messages`, by id and by reference. */
|
|
191
|
+
function openingOf(messages: readonly BaseMessage[]): {
|
|
192
|
+
id?: string;
|
|
193
|
+
message?: BaseMessage;
|
|
194
|
+
} {
|
|
195
|
+
const message = messages[turnStart(messages)];
|
|
196
|
+
return { id: message?.id, message };
|
|
106
197
|
}
|
|
107
198
|
|
|
108
199
|
/**
|
|
109
200
|
* Wraps LangChain's built-in `summarizationMiddleware` with the IXO-specific
|
|
110
201
|
* summary prompt + prefix. Pass any compatible chat model — typically the
|
|
111
202
|
* cheap "routing" role.
|
|
203
|
+
*
|
|
204
|
+
* A summary that fails is not attempted again before the next turn: the
|
|
205
|
+
* history it could not condense is the same one on every later step, and
|
|
206
|
+
* each attempt would send all of it again.
|
|
207
|
+
*
|
|
208
|
+
* A summary written in the middle of a turn — the turn's opening message is
|
|
209
|
+
* among what it condenses — carries the turn's earlier tool calls
|
|
210
|
+
* (turn-boundary.ts), so the repetition guard still sees them.
|
|
112
211
|
*/
|
|
113
212
|
export const createSummarizationMiddleware = (
|
|
114
213
|
options: SummarizationMiddlewareOptions,
|
|
@@ -129,6 +228,7 @@ export const createSummarizationMiddleware = (
|
|
|
129
228
|
{ tokens: options.triggerTokens ?? DEFAULT_TRIGGER_TOKENS },
|
|
130
229
|
],
|
|
131
230
|
keep: { messages: options.keepMessages ?? DEFAULT_KEEP_MESSAGES },
|
|
231
|
+
tokenCounter: countMessageTokensApproximately,
|
|
132
232
|
...(options.summaryInputTokens !== undefined
|
|
133
233
|
? { trimTokensToSummarize: options.summaryInputTokens }
|
|
134
234
|
: {}),
|
|
@@ -138,9 +238,21 @@ export const createSummarizationMiddleware = (
|
|
|
138
238
|
// The hook is either a bare handler or `{ hook, canJumpTo }`; keep the shape.
|
|
139
239
|
const handler =
|
|
140
240
|
typeof beforeModel === 'function' ? beforeModel : beforeModel.hook;
|
|
241
|
+
// The opening message of the turn whose summary failed.
|
|
242
|
+
let failedTurn: { id?: string; message?: BaseMessage } | undefined;
|
|
243
|
+
const sameTurn = (messages: readonly BaseMessage[]): boolean => {
|
|
244
|
+
if (!failedTurn) return false;
|
|
245
|
+
const now = openingOf(messages);
|
|
246
|
+
return (
|
|
247
|
+
(now.message !== undefined && now.message === failedTurn.message) ||
|
|
248
|
+
(now.id !== undefined && now.id === failedTurn.id)
|
|
249
|
+
);
|
|
250
|
+
};
|
|
141
251
|
// A summary that failed keeps the history exactly as it was: the turn
|
|
142
252
|
// runs on the full context and the next turn tries again.
|
|
143
253
|
const guarded: typeof handler = async (state, runtime) => {
|
|
254
|
+
options.budget?.check(runtime.signal);
|
|
255
|
+
if (sameTurn(state.messages)) return undefined;
|
|
144
256
|
const update = await handler(state, runtime);
|
|
145
257
|
const messages =
|
|
146
258
|
update && typeof update === 'object' && 'messages' in update
|
|
@@ -150,15 +262,18 @@ export const createSummarizationMiddleware = (
|
|
|
150
262
|
? messages.find((m) => isBaseMessageLike(m) && isFailedSummary(m))
|
|
151
263
|
: undefined;
|
|
152
264
|
if (failed) {
|
|
265
|
+
failedTurn = openingOf(state.messages);
|
|
153
266
|
options.logger?.warn(
|
|
154
|
-
`[summarization] summary failed; keeping the full history this turn: ${String(failed.content).slice(0, 300)}`,
|
|
267
|
+
`[summarization] summary failed; keeping the full history for the rest of this turn: ${String(failed.content).slice(0, 300)}`,
|
|
155
268
|
);
|
|
156
269
|
return undefined;
|
|
157
270
|
}
|
|
158
|
-
if (Array.isArray(messages) && messages.length > 0)
|
|
271
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
272
|
+
carryTurnCalls(state.messages, messages);
|
|
159
273
|
options.logger?.log(
|
|
160
274
|
`[summarization] condensed the history: ${state.messages.length} messages → a summary + ${messages.length - 2} kept`,
|
|
161
275
|
);
|
|
276
|
+
}
|
|
162
277
|
return update;
|
|
163
278
|
};
|
|
164
279
|
return {
|
|
@@ -170,6 +285,33 @@ export const createSummarizationMiddleware = (
|
|
|
170
285
|
};
|
|
171
286
|
};
|
|
172
287
|
|
|
288
|
+
/**
|
|
289
|
+
* When the summary in `update` removes the current turn's opening message,
|
|
290
|
+
* record on it the turn's tool calls it condensed (turn-boundary.ts).
|
|
291
|
+
*/
|
|
292
|
+
function carryTurnCalls(
|
|
293
|
+
before: readonly BaseMessage[],
|
|
294
|
+
update: readonly unknown[],
|
|
295
|
+
): void {
|
|
296
|
+
const summary = update.find(
|
|
297
|
+
(m): m is BaseMessage =>
|
|
298
|
+
isBaseMessageLike(m) &&
|
|
299
|
+
HumanMessage.isInstance(m) &&
|
|
300
|
+
isSummarizationMessage(m),
|
|
301
|
+
);
|
|
302
|
+
if (!summary) return;
|
|
303
|
+
const keptIds = new Set<string>();
|
|
304
|
+
for (const m of update)
|
|
305
|
+
if (isBaseMessageLike(m) && m !== summary && typeof m.id === 'string')
|
|
306
|
+
keptIds.add(m.id);
|
|
307
|
+
const carried = carriedCallsOfTurn(before, keptIds);
|
|
308
|
+
if (carried === undefined) return;
|
|
309
|
+
summary.additional_kwargs = {
|
|
310
|
+
...summary.additional_kwargs,
|
|
311
|
+
...turnCarryKwargs(carried),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
173
315
|
function isBaseMessageLike(value: unknown): value is BaseMessage {
|
|
174
316
|
return (
|
|
175
317
|
typeof value === 'object' &&
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a tool call is executed once the resume policy (tool-marks.ts) let it
|
|
3
|
+
* run: charged to the turn's budget, scheduled in its lane, and — for a
|
|
4
|
+
* write — claimed in the run ledger for as long as its outcome is unknown.
|
|
5
|
+
*
|
|
6
|
+
* The claim is a fingerprint of the tool name and its canonical arguments,
|
|
7
|
+
* recorded before the write starts. A returned result (success or a
|
|
8
|
+
* reported failure) releases it; a failure that says nothing about whether
|
|
9
|
+
* the write happened — abort, deadline, a dropped connection, a 5xx —
|
|
10
|
+
* leaves it in place, whether the tool threw it or caught it and returned
|
|
11
|
+
* it as an error result (many plugin tools and adapters never throw to the
|
|
12
|
+
* agent: `{ ok: false, error }`, an error-status ToolMessage, `Error: …`).
|
|
13
|
+
* An identical write attempted while a claim stands is
|
|
14
|
+
* not executed: the model is told the earlier outcome is unknown and asked
|
|
15
|
+
* to verify with a read (a `warned` claim). A later turn that asks for the
|
|
16
|
+
* same write again, after that warning, runs it — the user was informed and
|
|
17
|
+
* asked again — and the ledger drops claims with the run retention.
|
|
18
|
+
*
|
|
19
|
+
* Placed innermost around the tool (`MainAgentHooks.toolExecution`): it
|
|
20
|
+
* must see the tool's own thrown error, which the retry middlewares would
|
|
21
|
+
* otherwise have turned into an error ToolMessage, and each retry attempt
|
|
22
|
+
* is charged and scheduled again. Applied to every sub-agent's inner graph
|
|
23
|
+
* too, so a sub-agent's own writes are claimed. Sub-agent dispatches are
|
|
24
|
+
* counted as tool attempts and scheduled in their own lane, never
|
|
25
|
+
* fingerprinted.
|
|
26
|
+
*/
|
|
27
|
+
import { reportsUnknownOutcome } from '@ixo/common/ai/frontend-bridge';
|
|
28
|
+
import { ToolMessage } from '@langchain/core/messages';
|
|
29
|
+
import { type AgentMiddleware, createMiddleware } from 'langchain';
|
|
30
|
+
import type { Logger } from '../../plugin-api/types';
|
|
31
|
+
import type { ToolLane, ToolScheduler } from '../tool-scheduler';
|
|
32
|
+
import { isHarnessLimitError, type TurnBudget } from '../turn-budget';
|
|
33
|
+
import { NOOP_LOGGER } from '../utils';
|
|
34
|
+
|
|
35
|
+
export type WriteClaim =
|
|
36
|
+
| { status: 'claimed' }
|
|
37
|
+
| { status: 'blocked'; toolName: string; since: string };
|
|
38
|
+
|
|
39
|
+
/** The claim slice of the run ledger (see `RunStore`). */
|
|
40
|
+
export interface WriteClaimStore {
|
|
41
|
+
claimWrite(input: {
|
|
42
|
+
fingerprint: string;
|
|
43
|
+
toolName: string;
|
|
44
|
+
runId: string;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
}): Promise<WriteClaim>;
|
|
47
|
+
releaseWrite(fingerprint: string, runId: string): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ToolExecutionOptions {
|
|
51
|
+
budget: TurnBudget;
|
|
52
|
+
scheduler: ToolScheduler;
|
|
53
|
+
/** Lane of a tool by name (`read`, `write`, or `subagent` for a dispatch). */
|
|
54
|
+
laneOf: (toolName: string) => ToolLane;
|
|
55
|
+
runId: string;
|
|
56
|
+
sessionId: string;
|
|
57
|
+
/** Omitted → writes are scheduled and charged but not claimed (stateless builds, tests). */
|
|
58
|
+
claims?: WriteClaimStore;
|
|
59
|
+
/** The turn's abort signal: waiting for a slot ends with it. */
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
logger?: Logger;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stable text for a value: object keys sorted, arrays in order. */
|
|
65
|
+
export function canonicalArguments(value: unknown): string {
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return `[${value.map(canonicalArguments).join(',')}]`;
|
|
68
|
+
if (value && typeof value === 'object')
|
|
69
|
+
return `{${Object.entries(value)
|
|
70
|
+
.filter(([, item]) => item !== undefined)
|
|
71
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
72
|
+
.map(
|
|
73
|
+
([key, item]) => `${JSON.stringify(key)}:${canonicalArguments(item)}`,
|
|
74
|
+
)
|
|
75
|
+
.join(',')}}`;
|
|
76
|
+
return JSON.stringify(value) ?? 'null';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** SHA-256 of `name:args`, hex — the write's fingerprint in the ledger. */
|
|
80
|
+
export async function operationKey(
|
|
81
|
+
name: string,
|
|
82
|
+
args: unknown,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
const bytes = new TextEncoder().encode(`${name}:${canonicalArguments(args)}`);
|
|
85
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
86
|
+
return Array.from(new Uint8Array(digest), (b) =>
|
|
87
|
+
b.toString(16).padStart(2, '0'),
|
|
88
|
+
).join('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The result handed to the model for a write it must not repeat yet. */
|
|
92
|
+
export function uncertainWriteToolResult(toolName: string): string {
|
|
93
|
+
return `An identical ${toolName} call was started earlier and its outcome is unknown (it was interrupted or its connection dropped), so it was NOT run again: running it twice could repeat its effect. Verify with a read-only call whether it already happened and tell the user; only repeat it if they confirm.`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Transport error codes (Node / undici `code`s; workerd only has them in the
|
|
98
|
+
* message) and the editor's undelivered write (`write_not_saved`: the
|
|
99
|
+
* homeserver kept failing, and a send that failed with a server error may
|
|
100
|
+
* still have landed). Any of them can follow a request that reached the
|
|
101
|
+
* service, so none proves the effect did not happen.
|
|
102
|
+
*/
|
|
103
|
+
const UNCERTAIN_CODES =
|
|
104
|
+
'ECONNRESET|ECONNREFUSED|ECONNABORTED|ETIMEDOUT|EPIPE|UND_ERR_(?:SOCKET|CLOSED|CONNECT_TIMEOUT|HEADERS_TIMEOUT|BODY_TIMEOUT)|write_not_saved';
|
|
105
|
+
const UNCERTAIN_CODE = new RegExp(`^(?:${UNCERTAIN_CODES})$`, 'i');
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Failure text that says nothing about whether the side effect happened,
|
|
109
|
+
* anchored to how such a failure is reported — never a bare word a definite
|
|
110
|
+
* refusal can contain ("network 'base' not supported", "invalid timeout",
|
|
111
|
+
* "payment aborted by the user"):
|
|
112
|
+
* - an error code above, as a whole word;
|
|
113
|
+
* - a transport failure's own message: undici's `fetch failed`, `socket
|
|
114
|
+
* hang up`, workerd's `Network connection lost`, `network error`;
|
|
115
|
+
* - a deadline: `timed out`, `timeout exceeded` / `timeout of <n>`, the
|
|
116
|
+
* `TimeoutError` / `AbortError` names, DOMException's "operation was
|
|
117
|
+
* aborted";
|
|
118
|
+
* - a 5xx: its status (`HTTP 503`, `status 502`, `status code: 500`) or
|
|
119
|
+
* its reason phrase.
|
|
120
|
+
*/
|
|
121
|
+
const UNCERTAIN_TEXT = new RegExp(
|
|
122
|
+
[
|
|
123
|
+
`\\b(?:${UNCERTAIN_CODES})\\b`,
|
|
124
|
+
'\\bfetch failed\\b',
|
|
125
|
+
'\\bsocket hang up\\b',
|
|
126
|
+
'\\bnetwork (?:error|connection (?:was )?lost)\\b',
|
|
127
|
+
'\\btimed out\\b',
|
|
128
|
+
'\\btimeout (?:exceeded|of \\d)',
|
|
129
|
+
'\\b(?:TimeoutError|AbortError)\\b',
|
|
130
|
+
'\\boperation was aborted\\b',
|
|
131
|
+
'\\b(?:HTTP|status(?: code)?):? ?5\\d\\d\\b',
|
|
132
|
+
'\\b(?:internal server error|bad gateway|service unavailable|gateway time-?out)\\b',
|
|
133
|
+
].join('|'),
|
|
134
|
+
'i',
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const isServerStatus = (status: unknown): boolean =>
|
|
138
|
+
typeof status === 'number' && status >= 500 && status <= 599;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `true` when a thrown error says nothing about whether the tool's side
|
|
142
|
+
* effect happened: the turn was aborted or hit its deadline, the transport
|
|
143
|
+
* failed, or the service answered with a server error.
|
|
144
|
+
*/
|
|
145
|
+
export function isUncertainOutcome(
|
|
146
|
+
error: unknown,
|
|
147
|
+
signal?: AbortSignal,
|
|
148
|
+
): boolean {
|
|
149
|
+
if (signal?.aborted) return true;
|
|
150
|
+
if (isHarnessLimitError(error)) return true;
|
|
151
|
+
if (!(error instanceof Error)) return false;
|
|
152
|
+
if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
|
|
153
|
+
if (
|
|
154
|
+
'code' in error &&
|
|
155
|
+
typeof error.code === 'string' &&
|
|
156
|
+
UNCERTAIN_CODE.test(error.code)
|
|
157
|
+
)
|
|
158
|
+
return true;
|
|
159
|
+
const status =
|
|
160
|
+
'status' in error && typeof error.status === 'number'
|
|
161
|
+
? error.status
|
|
162
|
+
: undefined;
|
|
163
|
+
if (status !== undefined) return isServerStatus(status);
|
|
164
|
+
return UNCERTAIN_TEXT.test(error.message);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
168
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toolMessageText(message: ToolMessage): string {
|
|
172
|
+
const { content } = message;
|
|
173
|
+
if (typeof content === 'string') return content;
|
|
174
|
+
return content
|
|
175
|
+
.map((block) =>
|
|
176
|
+
typeof block === 'string'
|
|
177
|
+
? block
|
|
178
|
+
: 'text' in block && typeof block.text === 'string'
|
|
179
|
+
? block.text
|
|
180
|
+
: '',
|
|
181
|
+
)
|
|
182
|
+
.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The failure a JSON tool result reports, when it is one: `ok`, `success` or
|
|
187
|
+
* `successful` false, `isError` true, or an `error` field. The text joins
|
|
188
|
+
* whatever describes it (error, message, reason, code, detail) and a server
|
|
189
|
+
* status (`status` / `statusCode`) is carried apart.
|
|
190
|
+
*/
|
|
191
|
+
function reportedFailure(
|
|
192
|
+
body: Record<string, unknown>,
|
|
193
|
+
): { text: string; serverStatus: boolean } | null {
|
|
194
|
+
const failed =
|
|
195
|
+
body.ok === false ||
|
|
196
|
+
body.success === false ||
|
|
197
|
+
body.successful === false ||
|
|
198
|
+
body.isError === true ||
|
|
199
|
+
(body.error !== undefined && body.error !== null && body.error !== false);
|
|
200
|
+
if (!failed) return null;
|
|
201
|
+
const parts: string[] = [];
|
|
202
|
+
let serverStatus =
|
|
203
|
+
isServerStatus(body.status) || isServerStatus(body.statusCode);
|
|
204
|
+
const collect = (value: unknown): void => {
|
|
205
|
+
if (typeof value === 'string') parts.push(value);
|
|
206
|
+
else if (isRecord(value)) {
|
|
207
|
+
if (isServerStatus(value.status) || isServerStatus(value.statusCode))
|
|
208
|
+
serverStatus = true;
|
|
209
|
+
for (const key of ['message', 'code', 'name', 'reason', 'detail'])
|
|
210
|
+
collect(value[key]);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
for (const key of ['error', 'message', 'reason', 'code', 'name', 'detail'])
|
|
214
|
+
collect(body[key]);
|
|
215
|
+
return { text: parts.join(' '), serverStatus };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Why a RETURNED tool result leaves a write's outcome unknown, or null when
|
|
220
|
+
* it does not. A JSON result whose `outcome` is `'unknown'` says so itself.
|
|
221
|
+
* Otherwise only an error-shaped result counts — an error-status
|
|
222
|
+
* ToolMessage, a JSON failure (`reportedFailure`), or text that opens with
|
|
223
|
+
* `Error` / `Failed` — and only when what it reports is the uncertain kind
|
|
224
|
+
* `isUncertainOutcome` recognises in a thrown error (a timeout, a transport
|
|
225
|
+
* failure, a 5xx). A failure the service did report (validation, 404, 403)
|
|
226
|
+
* is a known outcome, and a success mentioning a timeout is not a failure.
|
|
227
|
+
*/
|
|
228
|
+
export function uncertainResultReason(output: unknown): string | null {
|
|
229
|
+
if (!ToolMessage.isInstance(output)) return null;
|
|
230
|
+
const text = toolMessageText(output).trim();
|
|
231
|
+
let failure: { text: string; serverStatus: boolean } | null = null;
|
|
232
|
+
try {
|
|
233
|
+
const parsed: unknown = JSON.parse(text);
|
|
234
|
+
// A result that says so itself (the frontend bridge's
|
|
235
|
+
// FRONTEND_OUTCOME_UNKNOWN, or a client reporting the same).
|
|
236
|
+
if (reportsUnknownOutcome(parsed)) return 'outcome unknown';
|
|
237
|
+
if (isRecord(parsed)) failure = reportedFailure(parsed);
|
|
238
|
+
} catch {
|
|
239
|
+
// Not JSON: plain text, judged below.
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
!failure &&
|
|
243
|
+
(output.status === 'error' || /^(error|failed)\b/i.test(text))
|
|
244
|
+
)
|
|
245
|
+
failure = { text, serverStatus: false };
|
|
246
|
+
if (!failure) return null;
|
|
247
|
+
if (failure.serverStatus) return 'server error';
|
|
248
|
+
const match = UNCERTAIN_TEXT.exec(failure.text);
|
|
249
|
+
return match ? match[0] : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function createToolExecutionMiddleware(
|
|
253
|
+
options: ToolExecutionOptions,
|
|
254
|
+
): AgentMiddleware {
|
|
255
|
+
const logger = options.logger ?? NOOP_LOGGER;
|
|
256
|
+
const { budget, scheduler, claims } = options;
|
|
257
|
+
// Executions per tool call id: a second one is a retry (the log is the
|
|
258
|
+
// operator's only view of a read that failed and then succeeded).
|
|
259
|
+
const attempts = new Map<string, number>();
|
|
260
|
+
return createMiddleware({
|
|
261
|
+
name: 'ToolExecutionMiddleware',
|
|
262
|
+
wrapToolCall: async (request, handler) => {
|
|
263
|
+
const { toolCall } = request;
|
|
264
|
+
const toolName = toolCall.name;
|
|
265
|
+
const lane = options.laneOf(toolName);
|
|
266
|
+
const callId = toolCall.id ?? '';
|
|
267
|
+
const attempt = (attempts.get(callId) ?? 0) + 1;
|
|
268
|
+
attempts.set(callId, attempt);
|
|
269
|
+
if (attempt > 1)
|
|
270
|
+
logger.log(
|
|
271
|
+
`[tool-execution] ${toolName} (${callId}): attempt ${attempt}`,
|
|
272
|
+
);
|
|
273
|
+
// Refused before waiting for a slot: a turn over its limit takes no
|
|
274
|
+
// more slots. The slot wait itself ends with the abort signal.
|
|
275
|
+
budget.check(options.signal);
|
|
276
|
+
return scheduler.run(lane, options.signal, async () => {
|
|
277
|
+
budget.reserveTool(options.signal);
|
|
278
|
+
if (lane !== 'write' || !claims) return handler(request);
|
|
279
|
+
const fingerprint = await operationKey(toolName, toolCall.args);
|
|
280
|
+
const claim = await claims.claimWrite({
|
|
281
|
+
fingerprint,
|
|
282
|
+
toolName,
|
|
283
|
+
runId: options.runId,
|
|
284
|
+
sessionId: options.sessionId,
|
|
285
|
+
});
|
|
286
|
+
if (claim.status === 'blocked') {
|
|
287
|
+
logger.warn(
|
|
288
|
+
`[tool-execution] ${toolName}: an identical write since ${claim.since} has no known outcome; not run again`,
|
|
289
|
+
);
|
|
290
|
+
return new ToolMessage({
|
|
291
|
+
tool_call_id: toolCall.id ?? '',
|
|
292
|
+
name: toolName,
|
|
293
|
+
content: uncertainWriteToolResult(toolName),
|
|
294
|
+
status: 'error',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const output = await handler(request);
|
|
299
|
+
const uncertain = uncertainResultReason(output);
|
|
300
|
+
if (uncertain !== null) {
|
|
301
|
+
logger.warn(
|
|
302
|
+
`[tool-execution] ${toolName}: returned a failure that leaves its outcome unknown (${uncertain}); its claim stays in the ledger`,
|
|
303
|
+
);
|
|
304
|
+
} else {
|
|
305
|
+
await claims.releaseWrite(fingerprint, options.runId);
|
|
306
|
+
}
|
|
307
|
+
return output;
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (isUncertainOutcome(error, options.signal)) {
|
|
310
|
+
logger.warn(
|
|
311
|
+
`[tool-execution] ${toolName}: outcome unknown (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); its claim stays in the ledger`,
|
|
312
|
+
);
|
|
313
|
+
} else {
|
|
314
|
+
await claims
|
|
315
|
+
.releaseWrite(fingerprint, options.runId)
|
|
316
|
+
.catch(() => undefined);
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|