@ixo/oracle-runtime-workers 0.41.1 → 0.41.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/package.json +2 -2
- 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/subagent-as-tool.ts +49 -2
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +114 -73
- package/src/plugins/pod-creator/sub-agents.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/oracle-runtime-workers",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.3",
|
|
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,9 +67,9 @@
|
|
|
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",
|
|
70
71
|
"@ixo/ucan": "2.3.0",
|
|
71
72
|
"@ixo/common": "1.8.0",
|
|
72
|
-
"@ixo/ixo-transaction": "0.2.0",
|
|
73
73
|
"@ixo/action-interactions": "0.1.1"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
@@ -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
|
);
|
|
@@ -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/plugin-api/types.ts
CHANGED
|
@@ -1317,6 +1317,13 @@ export interface PluginSubAgent {
|
|
|
1317
1317
|
*
|
|
1318
1318
|
* Runtime-injected passthrough tools (e.g. memory CRUD) are never
|
|
1319
1319
|
* forwarded — they're already on the main agent.
|
|
1320
|
+
*
|
|
1321
|
+
* Forwarded messages are for the chat: they stay in the thread (the UI
|
|
1322
|
+
* and the transcript show them) but are left out of the parent's model
|
|
1323
|
+
* requests. The parent model reads the sub-agent's final reply as the
|
|
1324
|
+
* call's result (its last tool result when that reply is empty), so the
|
|
1325
|
+
* sub-agent's reply must say what it did. Forward the calls the user
|
|
1326
|
+
* should see (a write, its receipt), not large reads.
|
|
1320
1327
|
*/
|
|
1321
1328
|
forwardTools?: boolean | string[];
|
|
1322
1329
|
/** Called after sub-agent completes; can emit follow-up events. */
|
|
@@ -903,6 +903,41 @@ function issuesOf(
|
|
|
903
903
|
);
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
/** Why a Hub action tool did nothing: Hub actions are off for this oracle. */
|
|
907
|
+
export const HUB_ACTIONS_OFF_REASON =
|
|
908
|
+
'Hub actions are not enabled for this oracle.';
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Why a Hub action tool did nothing on a Portal, Matrix or other non-channel
|
|
912
|
+
* turn. Auth Hub reviews are bound to a channel binding, so nothing can be
|
|
913
|
+
* prepared here; the reason says so in words the model can relay, so it
|
|
914
|
+
* never reads the refusal as an Auth Hub outage.
|
|
915
|
+
*/
|
|
916
|
+
export const NOT_A_CHANNEL_CHAT_REASON =
|
|
917
|
+
'Coin sends, chain transactions and other Auth Hub actions can be prepared only in a channel chat with this oracle (such as WhatsApp), not in this chat. Auth Hub was not contacted and nothing was prepared or sent. Tell the person to ask in their channel chat.';
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* The turn's channel binding and Hub client when Hub actions can be
|
|
921
|
+
* prepared on this turn, else the refusal that names why not.
|
|
922
|
+
*/
|
|
923
|
+
function channelTurnOf(ctx: RuntimeContext):
|
|
924
|
+
| {
|
|
925
|
+
channel: NonNullable<RuntimeContext['session']['channel']>;
|
|
926
|
+
hubActions: NonNullable<RuntimeContext['hubActions']>;
|
|
927
|
+
}
|
|
928
|
+
| { refusal: { status: 'unavailable'; reason: string } } {
|
|
929
|
+
if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
|
|
930
|
+
return {
|
|
931
|
+
refusal: { status: 'unavailable', reason: HUB_ACTIONS_OFF_REASON },
|
|
932
|
+
};
|
|
933
|
+
const channel = ctx.session.channel;
|
|
934
|
+
if (ctx.session.client !== 'channel' || !channel)
|
|
935
|
+
return {
|
|
936
|
+
refusal: { status: 'unavailable', reason: NOT_A_CHANNEL_CHAT_REASON },
|
|
937
|
+
};
|
|
938
|
+
return { channel, hubActions: ctx.hubActions };
|
|
939
|
+
}
|
|
940
|
+
|
|
906
941
|
/**
|
|
907
942
|
* The catalogue types this account may use: the Hub capabilities'
|
|
908
943
|
* `chainTx.messageTypes`, which follow the protocol level the Hub speaks
|
|
@@ -916,16 +951,11 @@ function issuesOf(
|
|
|
916
951
|
async function availableChainTxTypes(
|
|
917
952
|
ctx: RuntimeContext,
|
|
918
953
|
): Promise<readonly ChainTxMessageType[]> {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
ctx.session.client !== 'channel' ||
|
|
922
|
-
!ctx.session.channel ||
|
|
923
|
-
!ctx.hubActions
|
|
924
|
-
)
|
|
925
|
-
return [];
|
|
954
|
+
const turn = channelTurnOf(ctx);
|
|
955
|
+
if ('refusal' in turn) return [];
|
|
926
956
|
let chainTx;
|
|
927
957
|
try {
|
|
928
|
-
chainTx = (await
|
|
958
|
+
chainTx = (await turn.hubActions.capabilities({ turn: true })).chainTx;
|
|
929
959
|
} catch (error) {
|
|
930
960
|
ctx.logger.warn(
|
|
931
961
|
`[hub-actions] capabilities unavailable, no chain transaction types this turn: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -1101,12 +1131,16 @@ async function hubActionStatus(
|
|
|
1101
1131
|
ctx: RuntimeContext,
|
|
1102
1132
|
): Promise<unknown> {
|
|
1103
1133
|
const parsed = statusSchema.safeParse(input);
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1134
|
+
if (!parsed.success)
|
|
1135
|
+
return {
|
|
1136
|
+
status: 'unavailable',
|
|
1137
|
+
reason:
|
|
1138
|
+
'An actionId is required: the 32-character id of a Hub action prepared for this owner.',
|
|
1139
|
+
};
|
|
1140
|
+
// Not only a channel turn: an action prepared in a channel chat is read
|
|
1141
|
+
// from the Portal too, where the channel's conversation is mirrored.
|
|
1142
|
+
if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
|
|
1143
|
+
return { status: 'unavailable', reason: HUB_ACTIONS_OFF_REASON };
|
|
1110
1144
|
try {
|
|
1111
1145
|
const read = await ctx.hubActions.status(
|
|
1112
1146
|
ctx.user.did,
|
|
@@ -1138,15 +1172,10 @@ async function hubActionCapabilities(
|
|
|
1138
1172
|
_input: unknown,
|
|
1139
1173
|
ctx: RuntimeContext,
|
|
1140
1174
|
): Promise<unknown> {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
ctx.session.client !== 'channel' ||
|
|
1144
|
-
!ctx.session.channel ||
|
|
1145
|
-
!ctx.hubActions
|
|
1146
|
-
)
|
|
1147
|
-
return { status: 'unavailable' };
|
|
1175
|
+
const turn = channelTurnOf(ctx);
|
|
1176
|
+
if ('refusal' in turn) return turn.refusal;
|
|
1148
1177
|
try {
|
|
1149
|
-
return await
|
|
1178
|
+
return await turn.hubActions.capabilities();
|
|
1150
1179
|
} catch (error) {
|
|
1151
1180
|
const refused = refusalOf(error);
|
|
1152
1181
|
if (refused) return refused;
|
|
@@ -1158,17 +1187,12 @@ async function hubActionOptions(
|
|
|
1158
1187
|
_input: unknown,
|
|
1159
1188
|
ctx: RuntimeContext,
|
|
1160
1189
|
): Promise<unknown> {
|
|
1161
|
-
const
|
|
1162
|
-
if (
|
|
1163
|
-
|
|
1164
|
-
ctx.session.client !== 'channel' ||
|
|
1165
|
-
!channel ||
|
|
1166
|
-
!ctx.hubActions
|
|
1167
|
-
)
|
|
1168
|
-
return { status: 'unavailable' };
|
|
1190
|
+
const turn = channelTurnOf(ctx);
|
|
1191
|
+
if ('refusal' in turn) return turn.refusal;
|
|
1192
|
+
const { channel } = turn;
|
|
1169
1193
|
let options;
|
|
1170
1194
|
try {
|
|
1171
|
-
options = await
|
|
1195
|
+
options = await turn.hubActions.options({
|
|
1172
1196
|
ownerDid: ctx.user.did,
|
|
1173
1197
|
provider: channel.provider,
|
|
1174
1198
|
bindingId: channel.bindingId,
|
|
@@ -1184,6 +1208,60 @@ async function hubActionOptions(
|
|
|
1184
1208
|
return { ...options, ownerAddress: ixoAddressOf(ctx.user.did) };
|
|
1185
1209
|
}
|
|
1186
1210
|
|
|
1211
|
+
/**
|
|
1212
|
+
* The tools that prepare a Hub action, offered only on a channel turn
|
|
1213
|
+
* (`getRequestTools`): Auth Hub binds every review to the turn's channel
|
|
1214
|
+
* binding, so on a Portal, Matrix or other turn they could only refuse, and
|
|
1215
|
+
* a model offered them reads the refusal as an Auth Hub outage. Their
|
|
1216
|
+
* handlers still refuse off a channel turn, with a reason. Built once.
|
|
1217
|
+
*/
|
|
1218
|
+
export const HUB_ACTION_CHANNEL_TOOLS: readonly PluginTool[] = [
|
|
1219
|
+
{
|
|
1220
|
+
name: 'hub_action_capabilities',
|
|
1221
|
+
description:
|
|
1222
|
+
"Read this authenticated oracle account's enabled operation kinds and exact registered form field schema before proposing a Hub action.",
|
|
1223
|
+
schema: discoverSchema,
|
|
1224
|
+
effect: 'read',
|
|
1225
|
+
handler: hubActionCapabilities,
|
|
1226
|
+
},
|
|
1227
|
+
{
|
|
1228
|
+
name: 'hub_action_options',
|
|
1229
|
+
description:
|
|
1230
|
+
"Read the owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label to prepare a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units. ownerDid is the authenticated user's DID and ownerAddress their IXO account address.",
|
|
1231
|
+
schema: discoverSchema,
|
|
1232
|
+
effect: 'read',
|
|
1233
|
+
handler: hubActionOptions,
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
name: 'request_hub_action',
|
|
1237
|
+
description:
|
|
1238
|
+
'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Coin sends and other chain transactions have their own tool (request_chain_transaction). Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again. Limits are checked on the call; `startsAt` and `endsAt` are ISO 8601 with an offset, a saved `filename` ends in .txt, a form field `id` is lower_snake_case.',
|
|
1239
|
+
schema: requestSchema,
|
|
1240
|
+
effect: 'write',
|
|
1241
|
+
handler: requestHubAction,
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
name: 'describe_chain_messages',
|
|
1245
|
+
description: DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION,
|
|
1246
|
+
schema: describeChainMessagesSchema,
|
|
1247
|
+
effect: 'read',
|
|
1248
|
+
handler: describeChainMessages,
|
|
1249
|
+
},
|
|
1250
|
+
];
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* hub_action_status, offered on every turn: an action prepared in a channel
|
|
1254
|
+
* chat is followed from the Portal too, where that conversation is mirrored.
|
|
1255
|
+
*/
|
|
1256
|
+
export const HUB_ACTION_STATUS_TOOL: PluginTool = {
|
|
1257
|
+
name: 'hub_action_status',
|
|
1258
|
+
description:
|
|
1259
|
+
'Read the verified status of a Hub action already prepared for this owner. Never executes. A `cancelled` outcome was withdrawn before anything was sent, by the person or by Auth Hub (`meaning` says so): never say the person cancelled it. When `meaning` is present, it says what the outcome means for the person (for example a carried-out group proposal whose actions failed): follow it. Coin sends, chain transactions and other Hub actions are prepared only in a channel chat (such as WhatsApp): when this chat has no tool for one, tell the person to ask there, and never call it an Auth Hub outage.',
|
|
1260
|
+
schema: statusSchema,
|
|
1261
|
+
effect: 'read',
|
|
1262
|
+
handler: hubActionStatus,
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1187
1265
|
const manifest: PluginManifest = {
|
|
1188
1266
|
title: 'Auth Hub action review',
|
|
1189
1267
|
summary:
|
|
@@ -1224,63 +1302,26 @@ export class HubActionsPlugin extends OraclePlugin {
|
|
|
1224
1302
|
);
|
|
1225
1303
|
}
|
|
1226
1304
|
/**
|
|
1305
|
+
* The tools that prepare a Hub action, on a channel turn only (none on a
|
|
1306
|
+
* Portal, Matrix or other turn, where Auth Hub cannot bind a review).
|
|
1227
1307
|
* request_chain_transaction is built per turn: its schema names only the
|
|
1228
1308
|
* message types this account may use (Auth Hub capabilities, cached by
|
|
1229
1309
|
* the owner object), so 37 types do not reach a model that may use 4.
|
|
1230
1310
|
*/
|
|
1231
1311
|
override async getRequestTools(ctx: RuntimeContext): Promise<PluginTool[]> {
|
|
1312
|
+
if ('refusal' in channelTurnOf(ctx)) return [];
|
|
1232
1313
|
const [available, described] = await Promise.all([
|
|
1233
1314
|
chainTxTypesForTurn(ctx),
|
|
1234
1315
|
describedChainTxTypes(ctx, ctx.history.messages),
|
|
1235
1316
|
]);
|
|
1236
1317
|
const tool = chainTransactionTool(available, described);
|
|
1237
|
-
return tool ? [tool] : [];
|
|
1318
|
+
return [...HUB_ACTION_CHANNEL_TOOLS, ...(tool ? [tool] : [])];
|
|
1238
1319
|
}
|
|
1239
1320
|
/** Gives each model call the request tool with the fields described so far in the turn. */
|
|
1240
1321
|
override getRequestMiddlewares(ctx: RuntimeContext): AgentMiddleware[] {
|
|
1241
1322
|
return [chainTxFieldsMiddleware(ctx)];
|
|
1242
1323
|
}
|
|
1243
1324
|
override getTools(): PluginTool[] {
|
|
1244
|
-
return [
|
|
1245
|
-
{
|
|
1246
|
-
name: 'hub_action_capabilities',
|
|
1247
|
-
description:
|
|
1248
|
-
"Read this authenticated oracle account's enabled operation kinds and exact registered form field schema before proposing a Hub action.",
|
|
1249
|
-
schema: discoverSchema,
|
|
1250
|
-
effect: 'read',
|
|
1251
|
-
handler: hubActionCapabilities,
|
|
1252
|
-
},
|
|
1253
|
-
{
|
|
1254
|
-
name: 'hub_action_options',
|
|
1255
|
-
description:
|
|
1256
|
-
"Read the owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label to prepare a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units. ownerDid is the authenticated user's DID and ownerAddress their IXO account address.",
|
|
1257
|
-
schema: discoverSchema,
|
|
1258
|
-
effect: 'read',
|
|
1259
|
-
handler: hubActionOptions,
|
|
1260
|
-
},
|
|
1261
|
-
{
|
|
1262
|
-
name: 'request_hub_action',
|
|
1263
|
-
description:
|
|
1264
|
-
'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Coin sends and other chain transactions have their own tool (request_chain_transaction). Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again. Limits are checked on the call; `startsAt` and `endsAt` are ISO 8601 with an offset, a saved `filename` ends in .txt, a form field `id` is lower_snake_case.',
|
|
1265
|
-
schema: requestSchema,
|
|
1266
|
-
effect: 'write',
|
|
1267
|
-
handler: requestHubAction,
|
|
1268
|
-
},
|
|
1269
|
-
{
|
|
1270
|
-
name: 'describe_chain_messages',
|
|
1271
|
-
description: DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION,
|
|
1272
|
-
schema: describeChainMessagesSchema,
|
|
1273
|
-
effect: 'read',
|
|
1274
|
-
handler: describeChainMessages,
|
|
1275
|
-
},
|
|
1276
|
-
{
|
|
1277
|
-
name: 'hub_action_status',
|
|
1278
|
-
description:
|
|
1279
|
-
'Read the verified status of a Hub action already prepared for this owner. Never executes. A `cancelled` outcome was withdrawn before anything was sent, by the person or by Auth Hub (`meaning` says so): never say the person cancelled it. When `meaning` is present, it says what the outcome means for the person (for example a carried-out group proposal whose actions failed): follow it.',
|
|
1280
|
-
schema: statusSchema,
|
|
1281
|
-
effect: 'read',
|
|
1282
|
-
handler: hubActionStatus,
|
|
1283
|
-
},
|
|
1284
|
-
];
|
|
1325
|
+
return [HUB_ACTION_STATUS_TOOL];
|
|
1285
1326
|
}
|
|
1286
1327
|
}
|
|
@@ -153,7 +153,12 @@ async function buildSubAgent(
|
|
|
153
153
|
systemPrompt: await resolvePrompt(role, rt, capsules),
|
|
154
154
|
tools: roleTools(role, storeFor),
|
|
155
155
|
model: 'subagent',
|
|
156
|
-
|
|
156
|
+
// Only the write is forwarded (shown in the chat as the specialist's
|
|
157
|
+
// recorded section). Its `read_blueprint` reads stay inside the
|
|
158
|
+
// specialist: forwarded, each full-section read (often over 60 KB) landed
|
|
159
|
+
// in the main thread, was capped and saved, and the main model paged
|
|
160
|
+
// through it with read_result.
|
|
161
|
+
forwardTools: ['submit_section'],
|
|
157
162
|
};
|
|
158
163
|
}
|
|
159
164
|
|