@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.41.1",
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
- * A turn that dies between the model's tool call and the tool's result — the
3
- * user aborted it, or the runtime was reset while the tool ran — leaves the
4
- * checkpoint with an assistant message whose `tool_calls` have no
5
- * `ToolMessage`. OpenRouter chat completions tolerate that history; the
6
- * ChatGPT-subscription lane (Responses API) rejects every later turn on the
7
- * session with `400 No tool output found for function call …`. This
8
- * middleware answers each unanswered call, for the model request only, with
9
- * a tool result that says the call was interrupted. The checkpoint is left as
10
- * it is: nothing is rewritten in place, and the model is free to call the
11
- * tool again.
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 missing, otherwise a repaired copy. */
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
- * Insert a synthetic `ToolMessage` for every `tool_calls` entry of an
42
- * assistant message that has no `ToolMessage` among the tool messages that
43
- * directly follow it. The synthetic results go right after the real ones,
44
- * before the next non-tool message, which is the position every provider's
45
- * request converter requires.
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
- const toolMessage = messages[next] as ToolMessage;
63
- answered.add(toolMessage.tool_call_id);
64
- out.push(toolMessage);
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 { messages: repaired === 0 ? messages : out, repaired };
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 { messages, repaired } = repairDanglingToolCalls(request.messages);
92
- if (repaired === 0) return handler(request);
93
- logger.warn(
94
- `[DanglingToolCallRepair] ${repaired} tool call(s) had no result in the thread history; answered as interrupted for this model call`,
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}: call read_result({ id: "${meta.id}", offset: 0 }) and follow \`next\` to read it in chunks.`
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 { ToolMessage, type BaseMessage } from '@langchain/core/messages';
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 defaultChars = Math.min(4_000, maxChars);
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(defaultChars)
68
- .describe(`Length to read (max ${maxChars}).`),
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 { id, offset, length } = schema.parse(rawArgs);
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(new AIMessage({ content: '', tool_calls: rewritten }));
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 = lastMessageContent(messages).toString();
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)}`;
@@ -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
- if (
920
- ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
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 ctx.hubActions.capabilities({ turn: true })).chainTx;
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
- !parsed.success ||
1106
- !ctx.hubActions ||
1107
- ctx.config.HUB_ACTIONS_ENABLED !== 'true'
1108
- )
1109
- return { status: 'unavailable' };
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
- if (
1142
- ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
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 ctx.hubActions.capabilities();
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 channel = ctx.session.channel;
1162
- if (
1163
- ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
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 ctx.hubActions.options({
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
- forwardTools: true,
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