@ixo/oracle-runtime-workers 0.41.2 → 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/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": {
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"@ixo/topic-protocol": "1.0.0-rc.8",
|
|
69
69
|
"survey-core": "2.3.11",
|
|
70
70
|
"@ixo/ixo-transaction": "0.2.0",
|
|
71
|
-
"@ixo/common": "1.8.0",
|
|
72
71
|
"@ixo/ucan": "2.3.0",
|
|
72
|
+
"@ixo/common": "1.8.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. */
|
|
@@ -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
|
|