@ixo/oracle-runtime-workers 0.41.2 → 0.42.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/package.json +2 -2
- package/src/attachments/download.ts +1 -1
- package/src/core/middlewares/dangling-tool-calls.ts +112 -26
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/read-result-tool.ts +70 -7
- package/src/core/runtime-context.ts +2 -2
- package/src/core/subagent-as-tool.ts +49 -2
- package/src/do/ambient.ts +2 -1
- package/src/plugin-api/types.ts +11 -1
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +1 -1
- package/src/plugins/ixo-chain/http.ts +8 -4
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +14 -1
- package/src/plugins/ixo-chain/queries.ts +26 -0
- package/src/plugins/pod-creator/chain-gateway.ts +123 -31
- package/src/plugins/pod-creator/config.ts +81 -1
- package/src/plugins/pod-creator/create-session-store.ts +159 -7
- package/src/plugins/pod-creator/create-tools.ts +403 -28
- package/src/plugins/pod-creator/domain-card.ts +67 -0
- package/src/plugins/pod-creator/index.ts +24 -2
- package/src/plugins/pod-creator/ixo-chain-gateway.ts +901 -0
- package/src/plugins/pod-creator/matrix-media.ts +486 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +5 -2
- package/src/plugins/pod-creator/pod-creator.plugin.ts +30 -9
- package/src/plugins/pod-creator/stage.ts +30 -0
- package/src/plugins/pod-creator/sub-agents.ts +210 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"protobufjs": "^7.6.5",
|
|
68
68
|
"@ixo/topic-protocol": "1.0.0-rc.8",
|
|
69
69
|
"survey-core": "2.3.11",
|
|
70
|
-
"@ixo/ixo-transaction": "0.2.0",
|
|
71
70
|
"@ixo/common": "1.8.0",
|
|
71
|
+
"@ixo/ixo-transaction": "0.2.0",
|
|
72
72
|
"@ixo/ucan": "2.3.0",
|
|
73
73
|
"@ixo/action-interactions": "0.1.1"
|
|
74
74
|
},
|
|
@@ -168,7 +168,7 @@ export class HomeserverUnauthorizedError extends Error {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/** `mxc://<server>/<media id>`, the parts checked before they go into a URL. */
|
|
171
|
-
const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
|
|
171
|
+
export const MXC_PATTERN = /^mxc:\/\/([A-Za-z0-9.:[\]-]+)\/([A-Za-z0-9_-]+)$/;
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
174
|
* Open the media behind `mxc` on the homeserver as the account behind
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* tool
|
|
2
|
+
* Every provider's request converter needs each assistant message's
|
|
3
|
+
* `tool_calls` answered by the tool messages that directly follow it. Two
|
|
4
|
+
* histories break that, and this middleware mends both for the model request
|
|
5
|
+
* only (the checkpoint is left as it is: nothing is rewritten in place):
|
|
6
|
+
*
|
|
7
|
+
* - Messages a sub-agent forwarded into the chat (`forwardTools`, marked with
|
|
8
|
+
* `forwardedFrom`) are left out: they are for the UI, and the parent call's
|
|
9
|
+
* own result is what the model reads.
|
|
10
|
+
* - A result that arrived later. A sub-agent that forwards its own tool calls
|
|
11
|
+
* (`forwardTools`, `subagent-as-tool.ts`) hands back a `Command` whose
|
|
12
|
+
* forwarded assistant and tool messages come before the parent call's own
|
|
13
|
+
* result, so that result lands after them; with parallel calls, after
|
|
14
|
+
* another call's forwarded messages too. The result is moved up to its
|
|
15
|
+
* call. Before this, such a call read as interrupted and the model was told
|
|
16
|
+
* "nothing from it was recorded, call it again" for every sub-agent call
|
|
17
|
+
* that forwarded anything.
|
|
18
|
+
* - A result that never came. A turn that dies between the model's tool call
|
|
19
|
+
* and the tool's result (the user aborted it, or the runtime was reset
|
|
20
|
+
* while the tool ran) leaves the call unanswered. OpenRouter chat
|
|
21
|
+
* completions tolerate that history; the ChatGPT-subscription lane
|
|
22
|
+
* (Responses API) rejects every later turn on the session with
|
|
23
|
+
* `400 No tool output found for function call …`. Such a call is answered
|
|
24
|
+
* with a tool result that says it was interrupted, and the model is free to
|
|
25
|
+
* call the tool again.
|
|
12
26
|
*/
|
|
13
27
|
import {
|
|
14
28
|
type BaseMessage,
|
|
29
|
+
HumanMessage,
|
|
15
30
|
isAIMessage,
|
|
16
31
|
isToolMessage,
|
|
17
32
|
ToolMessage,
|
|
@@ -30,26 +45,73 @@ export function interruptedToolResult(toolName: string): string {
|
|
|
30
45
|
return `The call to ${toolName} was interrupted before it returned a result (the turn was aborted or the runtime restarted while it ran), so nothing from it was recorded. Call it again if its result is still needed.`;
|
|
31
46
|
}
|
|
32
47
|
|
|
48
|
+
/**
|
|
49
|
+
* `additional_kwargs` key a forwarded sub-agent message carries
|
|
50
|
+
* (`FORWARDED_FROM_KEY` in `subagent-as-tool.ts`, repeated here so the
|
|
51
|
+
* middleware does not import the sub-agent wrapper).
|
|
52
|
+
*/
|
|
53
|
+
const FORWARDED_FROM = 'forwardedFrom';
|
|
54
|
+
|
|
55
|
+
/** The messages without those a sub-agent forwarded into the chat: they are for the UI, not the model. */
|
|
56
|
+
export function withoutForwarded(messages: BaseMessage[]): BaseMessage[] {
|
|
57
|
+
const kept = messages.filter(
|
|
58
|
+
(message) =>
|
|
59
|
+
typeof message.additional_kwargs?.[FORWARDED_FROM] !== 'string',
|
|
60
|
+
);
|
|
61
|
+
return kept.length === messages.length ? messages : kept;
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
export interface DanglingToolCallRepair {
|
|
34
|
-
/** The original array when nothing was
|
|
65
|
+
/** The original array when nothing was changed, otherwise a repaired copy. */
|
|
35
66
|
messages: BaseMessage[];
|
|
36
|
-
/** How many tool calls were answered. */
|
|
67
|
+
/** How many tool calls were answered as interrupted (no result anywhere). */
|
|
37
68
|
repaired: number;
|
|
69
|
+
/** How many results were moved up to their call from later in the turn. */
|
|
70
|
+
regrouped: number;
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
-
|
|
74
|
+
* The index of the tool message answering `callId` after `from`, before the
|
|
75
|
+
* next human message (a result never answers a call of an earlier turn), and
|
|
76
|
+
* not already taken; -1 when there is none.
|
|
77
|
+
*/
|
|
78
|
+
function laterResult(
|
|
79
|
+
messages: readonly BaseMessage[],
|
|
80
|
+
from: number,
|
|
81
|
+
callId: string,
|
|
82
|
+
taken: ReadonlySet<number>,
|
|
83
|
+
): number {
|
|
84
|
+
for (let j = from; j < messages.length; j += 1) {
|
|
85
|
+
const candidate = messages[j]!;
|
|
86
|
+
if (HumanMessage.isInstance(candidate)) return -1;
|
|
87
|
+
if (
|
|
88
|
+
!taken.has(j) &&
|
|
89
|
+
isToolMessage(candidate) &&
|
|
90
|
+
candidate.tool_call_id === callId
|
|
91
|
+
)
|
|
92
|
+
return j;
|
|
93
|
+
}
|
|
94
|
+
return -1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Answer every `tool_calls` entry of an assistant message right after it:
|
|
99
|
+
* first with the tool messages that directly follow it, then with its result
|
|
100
|
+
* from later in the turn (moved up, see the file comment), else with a
|
|
101
|
+
* synthetic result that says the call was interrupted. Results go before the
|
|
102
|
+
* next non-tool message, the position every provider's request converter
|
|
103
|
+
* requires.
|
|
46
104
|
*/
|
|
47
105
|
export function repairDanglingToolCalls(
|
|
48
106
|
messages: BaseMessage[],
|
|
49
107
|
): DanglingToolCallRepair {
|
|
50
108
|
const out: BaseMessage[] = [];
|
|
109
|
+
/** Indexes of results moved up to their call; skipped where they were. */
|
|
110
|
+
const taken = new Set<number>();
|
|
51
111
|
let repaired = 0;
|
|
112
|
+
let regrouped = 0;
|
|
52
113
|
for (let i = 0; i < messages.length; i += 1) {
|
|
114
|
+
if (taken.has(i)) continue;
|
|
53
115
|
const message = messages[i]!;
|
|
54
116
|
out.push(message);
|
|
55
117
|
if (!isAIMessage(message)) continue;
|
|
@@ -59,13 +121,23 @@ export function repairDanglingToolCalls(
|
|
|
59
121
|
const answered = new Set<string>();
|
|
60
122
|
let next = i + 1;
|
|
61
123
|
while (next < messages.length && isToolMessage(messages[next]!)) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
124
|
+
if (!taken.has(next)) {
|
|
125
|
+
const toolMessage = messages[next] as ToolMessage;
|
|
126
|
+
answered.add(toolMessage.tool_call_id);
|
|
127
|
+
out.push(toolMessage);
|
|
128
|
+
}
|
|
65
129
|
next += 1;
|
|
66
130
|
}
|
|
67
131
|
for (const call of toolCalls) {
|
|
68
132
|
if (!call.id || answered.has(call.id)) continue;
|
|
133
|
+
const later = laterResult(messages, next, call.id, taken);
|
|
134
|
+
if (later !== -1) {
|
|
135
|
+
taken.add(later);
|
|
136
|
+
answered.add(call.id);
|
|
137
|
+
out.push(messages[later]!);
|
|
138
|
+
regrouped += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
69
141
|
out.push(
|
|
70
142
|
new ToolMessage({
|
|
71
143
|
tool_call_id: call.id,
|
|
@@ -78,7 +150,11 @@ export function repairDanglingToolCalls(
|
|
|
78
150
|
}
|
|
79
151
|
i = next - 1;
|
|
80
152
|
}
|
|
81
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
messages: repaired === 0 && regrouped === 0 ? messages : out,
|
|
155
|
+
repaired,
|
|
156
|
+
regrouped,
|
|
157
|
+
};
|
|
82
158
|
}
|
|
83
159
|
|
|
84
160
|
export const createDanglingToolCallRepairMiddleware = (
|
|
@@ -88,11 +164,21 @@ export const createDanglingToolCallRepairMiddleware = (
|
|
|
88
164
|
return createMiddleware({
|
|
89
165
|
name: 'DanglingToolCallRepairMiddleware',
|
|
90
166
|
wrapModelCall: async (request, handler) => {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
167
|
+
const visible = withoutForwarded(request.messages);
|
|
168
|
+
const { messages, repaired, regrouped } =
|
|
169
|
+
repairDanglingToolCalls(visible);
|
|
170
|
+
if (repaired === 0 && regrouped === 0)
|
|
171
|
+
return visible === request.messages
|
|
172
|
+
? handler(request)
|
|
173
|
+
: handler({ ...request, messages });
|
|
174
|
+
if (repaired > 0)
|
|
175
|
+
logger.warn(
|
|
176
|
+
`[DanglingToolCallRepair] ${repaired} tool call(s) had no result in the thread history; answered as interrupted for this model call`,
|
|
177
|
+
);
|
|
178
|
+
if (regrouped > 0)
|
|
179
|
+
logger.debug?.(
|
|
180
|
+
`[DanglingToolCallRepair] ${regrouped} tool result(s) moved up to their call (forwarded sub-agent messages came between)`,
|
|
181
|
+
);
|
|
96
182
|
return handler({ ...request, messages });
|
|
97
183
|
},
|
|
98
184
|
});
|
|
@@ -102,7 +102,7 @@ export function capFooter(
|
|
|
102
102
|
tail: number,
|
|
103
103
|
): string {
|
|
104
104
|
const where = meta.id
|
|
105
|
-
? `The full result is saved as ${meta.id}
|
|
105
|
+
? `The full result is saved as ${meta.id}; if your task needs a part that is not shown, read_result({ id: "${meta.id}", offset }) reads it. Do not read it all to check that the call worked.`
|
|
106
106
|
: 'The full result could not be saved.';
|
|
107
107
|
return `\n\n[Result truncated: showing the first ${head} and last ${tail} of ${meta.size} characters. ${where}]`;
|
|
108
108
|
}
|
|
@@ -7,12 +7,17 @@
|
|
|
7
7
|
* ranges, of a trimmed one character ranges; `next` is the offset of the
|
|
8
8
|
* following chunk or `null` at the end.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AIMessage,
|
|
12
|
+
ToolMessage,
|
|
13
|
+
type BaseMessage,
|
|
14
|
+
} from '@langchain/core/messages';
|
|
11
15
|
import { z } from 'zod';
|
|
12
16
|
import { tool } from '../plugin-api/tool-helper';
|
|
13
17
|
import type { PluginTool } from '../plugin-api/types';
|
|
14
18
|
import type { ReadResultOutcome } from '../do/result-store';
|
|
15
19
|
import { contentToText } from './middlewares/result-cap';
|
|
20
|
+
import { turnStart } from './middlewares/turn-boundary';
|
|
16
21
|
|
|
17
22
|
export interface ReadResultToolOptions {
|
|
18
23
|
read: (
|
|
@@ -26,6 +31,58 @@ export interface ReadResultToolOptions {
|
|
|
26
31
|
|
|
27
32
|
export const READ_RESULT_TOOL_NAME = 'read_result';
|
|
28
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Reads one turn may make. Paging is for the part of a result a task needs,
|
|
36
|
+
* not for checking that a call worked: a model that read every chunk of a
|
|
37
|
+
* saved result (hundreds of characters at a time) spent a turn and two
|
|
38
|
+
* million tokens on it. Six of the largest chunks cover a result of about
|
|
39
|
+
* 96,000 characters.
|
|
40
|
+
*/
|
|
41
|
+
export const READ_RESULT_CALLS_PER_TURN = 6;
|
|
42
|
+
|
|
43
|
+
/** The smallest chunk read: a request for less reads this much, so a turn's reads are never spent on slivers. */
|
|
44
|
+
export const READ_RESULT_MIN_CHARS = 2_000;
|
|
45
|
+
|
|
46
|
+
/** The result of a read past {@link READ_RESULT_CALLS_PER_TURN}: not run. */
|
|
47
|
+
export function readBudgetSpent(reads: number): string {
|
|
48
|
+
return `Not read: this turn already read ${reads} chunks, the most one turn may read. Continue with what you have (the head and tail of the result, and each call's own reply, say whether it worked), or tell the person what is still unread.`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads this turn asked for before the call `toolCallId`, counted on the
|
|
53
|
+
* model's own tool calls so a batch of parallel reads counts each one (their
|
|
54
|
+
* results are not in the thread yet when they run). Without the call's id
|
|
55
|
+
* in the thread, the reads that already returned.
|
|
56
|
+
*/
|
|
57
|
+
function readsBefore(
|
|
58
|
+
messages: readonly BaseMessage[],
|
|
59
|
+
toolCallId: string | undefined,
|
|
60
|
+
): number {
|
|
61
|
+
const start = turnStart(messages);
|
|
62
|
+
if (toolCallId) {
|
|
63
|
+
let asked = 0;
|
|
64
|
+
for (let i = start; i < messages.length; i += 1) {
|
|
65
|
+
const message = messages[i];
|
|
66
|
+
if (!AIMessage.isInstance(message)) continue;
|
|
67
|
+
for (const call of message.tool_calls ?? []) {
|
|
68
|
+
if (call.name !== READ_RESULT_TOOL_NAME) continue;
|
|
69
|
+
if (call.id === toolCallId) return asked;
|
|
70
|
+
asked += 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let returned = 0;
|
|
75
|
+
for (let i = start; i < messages.length; i += 1) {
|
|
76
|
+
const message = messages[i];
|
|
77
|
+
if (
|
|
78
|
+
ToolMessage.isInstance(message) &&
|
|
79
|
+
message.name === READ_RESULT_TOOL_NAME
|
|
80
|
+
)
|
|
81
|
+
returned += 1;
|
|
82
|
+
}
|
|
83
|
+
return returned;
|
|
84
|
+
}
|
|
85
|
+
|
|
29
86
|
/** The longest chunk `read_result` hands back under a result cap: well under the cap, so a page is never capped itself. */
|
|
30
87
|
export function readResultMaxChars(resultCapChars: number): number {
|
|
31
88
|
return Math.min(16_000, Math.max(512, Math.floor(resultCapChars / 2)));
|
|
@@ -51,7 +108,7 @@ export function buildReadResultTool(
|
|
|
51
108
|
options: ReadResultToolOptions,
|
|
52
109
|
): PluginTool {
|
|
53
110
|
const maxChars = Math.max(512, Math.min(options.maxChars, 64 * 1024));
|
|
54
|
-
const
|
|
111
|
+
const minChars = Math.min(READ_RESULT_MIN_CHARS, maxChars);
|
|
55
112
|
const schema = z.object({
|
|
56
113
|
id: z
|
|
57
114
|
.string()
|
|
@@ -64,12 +121,19 @@ export function buildReadResultTool(
|
|
|
64
121
|
.int()
|
|
65
122
|
.min(256)
|
|
66
123
|
.max(maxChars)
|
|
67
|
-
.default(
|
|
68
|
-
.describe(
|
|
124
|
+
.default(maxChars)
|
|
125
|
+
.describe(
|
|
126
|
+
`Length to read (default and max ${maxChars}; at least ${minChars} is read).`,
|
|
127
|
+
),
|
|
69
128
|
});
|
|
70
129
|
return tool(
|
|
71
130
|
async (rawArgs, ctx) => {
|
|
72
|
-
const
|
|
131
|
+
const parsed = schema.parse(rawArgs);
|
|
132
|
+
const reads = readsBefore(ctx.history.messages, ctx.toolCallId);
|
|
133
|
+
if (reads >= READ_RESULT_CALLS_PER_TURN)
|
|
134
|
+
return { error: readBudgetSpent(reads) };
|
|
135
|
+
const { id, offset } = parsed;
|
|
136
|
+
const length = Math.max(parsed.length, minChars);
|
|
73
137
|
const text = threadResult(ctx.history.messages, id);
|
|
74
138
|
if (text !== undefined) {
|
|
75
139
|
const end = offset + length;
|
|
@@ -103,8 +167,7 @@ export function buildReadResultTool(
|
|
|
103
167
|
name: READ_RESULT_TOOL_NAME,
|
|
104
168
|
effect: 'read',
|
|
105
169
|
visibility: 'always',
|
|
106
|
-
description:
|
|
107
|
-
'Read a tool result you no longer have in full, in chunks. Use the id from a "[Result truncated …]" footer or a stub that says the result was removed; start at offset 0 and pass the returned `next` as the offset of the following call until `next` is null.',
|
|
170
|
+
description: `Read part of a tool result you no longer have in full: the id from a "[Result truncated …]" footer or a stub that says the result was removed. Read only the part your task needs, in large chunks (pass a returned \`next\` as the offset to continue). Never read a result back to check that a call worked: its visible head and tail and the call's own reply say that. At most ${READ_RESULT_CALLS_PER_TURN} reads per turn.`,
|
|
108
171
|
schema,
|
|
109
172
|
},
|
|
110
173
|
);
|
|
@@ -95,7 +95,7 @@ export interface MatrixAdapter {
|
|
|
95
95
|
getRoomState(roomId: string): Promise<RoomStateSnapshot>;
|
|
96
96
|
getEventById(roomId: string, eventId: string): Promise<MatrixEvent>;
|
|
97
97
|
/** See `MatrixGatewayObject.botCredentials`. */
|
|
98
|
-
botCredentials(): Promise<BotCredentials>;
|
|
98
|
+
botCredentials(opts?: { rejected?: string }): Promise<BotCredentials>;
|
|
99
99
|
/** Group rooms: room kind and channel memory (see `plugin-api/types.ts`). Hosts without a gateway omit them. */
|
|
100
100
|
roomInfo?: RuntimeContext['matrix']['roomInfo'];
|
|
101
101
|
channelMemory?: RuntimeContext['matrix']['channelMemory'];
|
|
@@ -497,7 +497,7 @@ export function buildRuntimeContext<TConfig = MergedConfig>(
|
|
|
497
497
|
matrix: {
|
|
498
498
|
postToRoom: (roomId, content, opts) =>
|
|
499
499
|
ambient.matrix.postToRoom(roomId, content, opts),
|
|
500
|
-
botCredentials: () => ambient.matrix.botCredentials(),
|
|
500
|
+
botCredentials: (opts) => ambient.matrix.botCredentials(opts),
|
|
501
501
|
postEvent: (roomId, eventType, content, opts) =>
|
|
502
502
|
ambient.matrix.postEvent(roomId, eventType, content, opts),
|
|
503
503
|
getRoomState: (roomId) => ambient.matrix.getRoomState(roomId),
|
|
@@ -97,6 +97,34 @@ const taskSchema = z.object({
|
|
|
97
97
|
),
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
/** Characters of a sub-agent's last tool result handed back when it ended without a reply. */
|
|
101
|
+
export const SUBAGENT_FALLBACK_RESULT_CHARS = 2_000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What the parent reads as a sub-agent's result: its final reply, or, when
|
|
105
|
+
* the sub-agent ended on an empty one (a model that stops right after its
|
|
106
|
+
* last tool call, such as a POD specialist after `submit_section`), its last
|
|
107
|
+
* tool result, labelled as such and cut to
|
|
108
|
+
* {@link SUBAGENT_FALLBACK_RESULT_CHARS}. An empty result leaves the parent
|
|
109
|
+
* nothing to tell whether the work was done.
|
|
110
|
+
*/
|
|
111
|
+
export function subAgentResultText(
|
|
112
|
+
messages: BaseMessage[],
|
|
113
|
+
name: string,
|
|
114
|
+
): string {
|
|
115
|
+
const text = lastMessageContent(messages).toString();
|
|
116
|
+
if (text.trim()) return text;
|
|
117
|
+
const lastTool = messages.findLast((message) =>
|
|
118
|
+
ToolMessage.isInstance(message),
|
|
119
|
+
);
|
|
120
|
+
if (!lastTool) return `${name} finished without a reply.`;
|
|
121
|
+
const result =
|
|
122
|
+
typeof lastTool.content === 'string'
|
|
123
|
+
? lastTool.content
|
|
124
|
+
: JSON.stringify(lastTool.content);
|
|
125
|
+
return `${name} finished without a reply. Its last tool call${lastTool.name ? ` (${lastTool.name})` : ''} returned: ${result.slice(0, SUBAGENT_FALLBACK_RESULT_CHARS)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
100
128
|
function lastMessageContent(messages: BaseMessage[]): string {
|
|
101
129
|
const last = messages.at(-1);
|
|
102
130
|
if (!last?.content) return '';
|
|
@@ -120,6 +148,18 @@ function lastMessageContent(messages: BaseMessage[]): string {
|
|
|
120
148
|
* LangChain-generated ids starting at 0, and two invocations in one chat
|
|
121
149
|
* collide — the frontend uses these ids as React keys.
|
|
122
150
|
*/
|
|
151
|
+
/**
|
|
152
|
+
* `additional_kwargs` key on every forwarded message: the parent call it came
|
|
153
|
+
* from. Forwarded messages are for the chat (the UI renders the sub-agent's
|
|
154
|
+
* calls); the parent model reads the call's own result instead, so the
|
|
155
|
+
* dangling-call middleware leaves them out of its requests
|
|
156
|
+
* (`withoutForwarded`). Without that, the parent model took a forwarded call
|
|
157
|
+
* for one of its own (it called a specialist's `submit_section` itself) and
|
|
158
|
+
* read each forwarded call's arguments, a whole POD section, on every model
|
|
159
|
+
* call of the thread.
|
|
160
|
+
*/
|
|
161
|
+
export const FORWARDED_FROM_KEY = 'forwardedFrom';
|
|
162
|
+
|
|
123
163
|
export function filterForwardedMessages(
|
|
124
164
|
messages: BaseMessage[],
|
|
125
165
|
forwardTools: Set<string>,
|
|
@@ -140,7 +180,13 @@ export function filterForwardedMessages(
|
|
|
140
180
|
oldToNewId.set(tc.id, newId);
|
|
141
181
|
return { ...tc, id: newId };
|
|
142
182
|
});
|
|
143
|
-
acc.push(
|
|
183
|
+
acc.push(
|
|
184
|
+
new AIMessage({
|
|
185
|
+
content: '',
|
|
186
|
+
tool_calls: rewritten,
|
|
187
|
+
additional_kwargs: { [FORWARDED_FROM_KEY]: idPrefix },
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
144
190
|
}
|
|
145
191
|
|
|
146
192
|
if (msg.type === 'tool') {
|
|
@@ -152,6 +198,7 @@ export function filterForwardedMessages(
|
|
|
152
198
|
content: toolMsg.content,
|
|
153
199
|
tool_call_id: newId,
|
|
154
200
|
...(toolMsg.name ? { name: toolMsg.name } : {}),
|
|
201
|
+
additional_kwargs: { [FORWARDED_FROM_KEY]: idPrefix },
|
|
155
202
|
}),
|
|
156
203
|
);
|
|
157
204
|
}
|
|
@@ -273,7 +320,7 @@ export function createSubagentAsTool(
|
|
|
273
320
|
messages: BaseMessage[],
|
|
274
321
|
toolCallId: string,
|
|
275
322
|
): string | Command => {
|
|
276
|
-
const text =
|
|
323
|
+
const text = subAgentResultText(messages, spec.name);
|
|
277
324
|
if (forwardSet.size === 0) return text;
|
|
278
325
|
|
|
279
326
|
const idPrefix = toolCallId || `run_${crypto.randomUUID().slice(0, 8)}`;
|
package/src/do/ambient.ts
CHANGED
|
@@ -183,7 +183,8 @@ export function createMatrixAdapter(
|
|
|
183
183
|
),
|
|
184
184
|
postEvent: (roomId, eventType, content, opts) =>
|
|
185
185
|
gateway.sendEvent(roomId, eventType, JSON.stringify(content), opts ?? {}),
|
|
186
|
-
botCredentials: () =>
|
|
186
|
+
botCredentials: (opts?: { rejected?: string }) =>
|
|
187
|
+
gateway.botCredentials(opts?.rejected ? { rejected: opts.rejected } : {}),
|
|
187
188
|
roomInfo: (roomId) => gateway.groupChatRoomInfo(roomId),
|
|
188
189
|
channelMemory: {
|
|
189
190
|
recall: async (roomId, limit) =>
|
package/src/plugin-api/types.ts
CHANGED
|
@@ -818,8 +818,11 @@ export interface RuntimeContext<TConfig = MergedConfig> {
|
|
|
818
818
|
/**
|
|
819
819
|
* Credentials of the oracle's own crypto-less device for plugins that run
|
|
820
820
|
* their own Matrix client (editor, flows). Minted and kept by the gateway.
|
|
821
|
+
* A caller whose request the homeserver answered 401 passes the token it
|
|
822
|
+
* used as `rejected`: the host then re-validates the device (or logs it
|
|
823
|
+
* in again) and answers with fresh credentials, so one retry can succeed.
|
|
821
824
|
*/
|
|
822
|
-
botCredentials: () => Promise<{
|
|
825
|
+
botCredentials: (opts?: { rejected?: string }) => Promise<{
|
|
823
826
|
baseUrl: string;
|
|
824
827
|
userId: string;
|
|
825
828
|
accessToken: string;
|
|
@@ -1317,6 +1320,13 @@ export interface PluginSubAgent {
|
|
|
1317
1320
|
*
|
|
1318
1321
|
* Runtime-injected passthrough tools (e.g. memory CRUD) are never
|
|
1319
1322
|
* forwarded — they're already on the main agent.
|
|
1323
|
+
*
|
|
1324
|
+
* Forwarded messages are for the chat: they stay in the thread (the UI
|
|
1325
|
+
* and the transcript show them) but are left out of the parent's model
|
|
1326
|
+
* requests. The parent model reads the sub-agent's final reply as the
|
|
1327
|
+
* call's result (its last tool result when that reply is empty), so the
|
|
1328
|
+
* sub-agent's reply must say what it did. Forward the calls the user
|
|
1329
|
+
* should see (a write, its receipt), not large reads.
|
|
1320
1330
|
*/
|
|
1321
1331
|
forwardTools?: boolean | string[];
|
|
1322
1332
|
/** Called after sub-agent completes; can emit follow-up events. */
|
|
@@ -14,7 +14,7 @@ import { createDomainIndexerTools } from './domain-indexer-tools';
|
|
|
14
14
|
* the core `NETWORK` env var. Operators can override with the optional
|
|
15
15
|
* `DOMAIN_INDEXER_URL` env var (useful for local/staging deployments).
|
|
16
16
|
*/
|
|
17
|
-
const DOMAIN_INDEXER_URLS = {
|
|
17
|
+
export const DOMAIN_INDEXER_URLS = {
|
|
18
18
|
mainnet: 'https://domain-indexer.ixo.earth',
|
|
19
19
|
testnet: 'https://domain-indexer.testnet.ixo.earth',
|
|
20
20
|
devnet: 'https://domain-indexer.devnet.ixo.earth',
|
|
@@ -151,6 +151,8 @@ export interface ChainCall {
|
|
|
151
151
|
deadline: number;
|
|
152
152
|
signal: AbortSignal;
|
|
153
153
|
fetch: typeof fetch;
|
|
154
|
+
/** How the call waits before a retry; {@link sleep} unless a test injects its own clock. */
|
|
155
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
/**
|
|
@@ -192,7 +194,7 @@ export type Outcome<T> =
|
|
|
192
194
|
| { ok: false; failure: Failure };
|
|
193
195
|
|
|
194
196
|
/** The turn's abort reason as an error (a non-error reason becomes an `AbortError`). */
|
|
195
|
-
function abortReason(signal: AbortSignal): Error {
|
|
197
|
+
export function abortReason(signal: AbortSignal): Error {
|
|
196
198
|
return signal.reason instanceof Error
|
|
197
199
|
? signal.reason
|
|
198
200
|
: new DOMException('Aborted', 'AbortError');
|
|
@@ -288,7 +290,7 @@ export class ReadsInFlight<T> {
|
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
/** Resolves after `ms`; rejects with the turn's abort reason if it ends first. */
|
|
291
|
-
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
293
|
+
export function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
292
294
|
return new Promise((resolve, reject) => {
|
|
293
295
|
if (signal.aborted) {
|
|
294
296
|
reject(abortReason(signal));
|
|
@@ -394,7 +396,7 @@ export async function withRetry<T>(
|
|
|
394
396
|
: { ok: false, failure: first.failure };
|
|
395
397
|
if (!first.retry || Date.now() + wait + MIN_ATTEMPT_MS > call.deadline)
|
|
396
398
|
return giveUp();
|
|
397
|
-
await sleep(wait, call.signal);
|
|
399
|
+
await (call.sleep ?? sleep)(wait, call.signal);
|
|
398
400
|
const second = await attempt();
|
|
399
401
|
recordHealth(read.upstream, second);
|
|
400
402
|
if (second.ok) {
|
|
@@ -518,7 +520,9 @@ const outOfBudget = (note: string): Attempt<never> => ({
|
|
|
518
520
|
* The body of an error answer as text, or undefined when it is longer than
|
|
519
521
|
* {@link ERROR_BODY_MAX_BYTES} (the rest is not read).
|
|
520
522
|
*/
|
|
521
|
-
async function errorBodyOf(
|
|
523
|
+
export async function errorBodyOf(
|
|
524
|
+
response: Response,
|
|
525
|
+
): Promise<string | undefined> {
|
|
522
526
|
const reader = response.body?.getReader();
|
|
523
527
|
if (!reader) return '';
|
|
524
528
|
const decoder = new TextDecoder();
|
|
@@ -79,12 +79,25 @@ function networkOf(config: MergedConfig): Network {
|
|
|
79
79
|
|
|
80
80
|
/** The REST base URL: `CHAIN_REST_URL` when set, else the network's default. */
|
|
81
81
|
export function chainRestUrl(config: MergedConfig): string {
|
|
82
|
+
return chainRestUrlFor(networkOf(config), config);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The REST base URL for `network`: `CHAIN_REST_URL` when set (the
|
|
87
|
+
* operator's node for the oracle's network), else that network's default.
|
|
88
|
+
* For a caller that already knows its network rather than reading
|
|
89
|
+
* `NETWORK` itself.
|
|
90
|
+
*/
|
|
91
|
+
export function chainRestUrlFor(
|
|
92
|
+
network: Network,
|
|
93
|
+
config: MergedConfig,
|
|
94
|
+
): string {
|
|
82
95
|
const override = configSchema.shape.CHAIN_REST_URL.safeParse(
|
|
83
96
|
config.CHAIN_REST_URL,
|
|
84
97
|
);
|
|
85
98
|
return override.success && override.data
|
|
86
99
|
? override.data
|
|
87
|
-
: CHAIN_REST_URLS[
|
|
100
|
+
: CHAIN_REST_URLS[network];
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
/** The names worker's origin: `IXO_NAMES_URL` when set, else the network's default. */
|
|
@@ -221,6 +221,32 @@ export function isBech32(address: string, prefix: string): boolean {
|
|
|
221
221
|
return padding < 5 && (last & ((1 << padding) - 1)) === 0;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The bytes a valid bech32 `address` with the prefix `prefix` carries (for
|
|
226
|
+
* an account, the 20-byte hash of its public key); null when
|
|
227
|
+
* {@link isBech32} refuses it.
|
|
228
|
+
*/
|
|
229
|
+
export function bech32Bytes(
|
|
230
|
+
address: string,
|
|
231
|
+
prefix: string,
|
|
232
|
+
): Uint8Array | null {
|
|
233
|
+
if (!isBech32(address, prefix)) return null;
|
|
234
|
+
const data = address.slice(prefix.length + 1, -BECH32_CHECKSUM);
|
|
235
|
+
const bytes: number[] = [];
|
|
236
|
+
let buffer = 0;
|
|
237
|
+
let bits = 0;
|
|
238
|
+
for (const char of data) {
|
|
239
|
+
buffer = (buffer << 5) | BECH32_CHARSET.indexOf(char);
|
|
240
|
+
bits += 5;
|
|
241
|
+
if (bits >= 8) {
|
|
242
|
+
bits -= 8;
|
|
243
|
+
bytes.push((buffer >>> bits) & 0xff);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// The leftover bits are the zero padding isBech32 checked.
|
|
247
|
+
return Uint8Array.from(bytes);
|
|
248
|
+
}
|
|
249
|
+
|
|
224
250
|
/**
|
|
225
251
|
* The account an account field names: the user's own address when the
|
|
226
252
|
* field is omitted, else an `ixo1…` address or the address a
|