Skip to content

Commit 0a21936

Browse files
BunsDevclaude
andcommitted
refactor: split tools/builtin/runtime.mjs into decisions + content
runtime.mjs at 215 lines was the shared support layer that every builtin tool imports from. Three concerns lived together: primitive ID/event/block builders, plugin decision validation/dispatching, and content-block normalization. Split into: - runtime.mjs (85) — primitives + barrel re-exports: createToolUseID, relativeToolPath, pluginToolCallEvent, pluginToolResultEvent, pluginToolUseBlock, toolResultContent, toolCallDecisionToolRun, pluginResultOutput, pluginTextToolRunResult, permissionDeniedOutput. Re-exports the decision and content names so the 18 builtin tool files keep their existing `from './runtime.mjs'` imports. - runtime-decisions.mjs (115) — plugin decision validation + dispatch: validateToolCallDecision, applyToolCallDecision, validateToolResultDecision, pluginToolResultDecisionOutput, pluginToolResultDecisionExitCode, plus the local isPlainObject validator helper. - runtime-content.mjs (31) — content block normalization: isPluginContentBlock, pluginContentBlockText, normalizePluginToolOutput, normalizePluginToolExecuteOutput. Dep graph one-way: runtime → decisions (for the exit-code/output accessors used inside pluginTextToolRunResult). No cycles, no caller changes. 315/315 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 885d6ea commit 0a21936

3 files changed

Lines changed: 164 additions & 148 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export function isPluginContentBlock(block) {
2+
if (!block || typeof block !== 'object') return false;
3+
if (block.type === 'text') return typeof block.text === 'string';
4+
if (block.type === 'image') return typeof block.mimeType === 'string' && typeof block.data === 'string';
5+
return false;
6+
}
7+
8+
export function pluginContentBlockText(block) {
9+
if (block.type === 'text') return block.text;
10+
return '';
11+
}
12+
13+
export function normalizePluginToolOutput(output) {
14+
if (Array.isArray(output) && output.every(isPluginContentBlock)) {
15+
const text = output.map(pluginContentBlockText).filter(Boolean).join('\n').trimEnd();
16+
return { raw: output, text };
17+
}
18+
const text = String(output ?? '').trimEnd();
19+
return { raw: text, text };
20+
}
21+
22+
export function normalizePluginToolExecuteOutput(output) {
23+
if (output === undefined || typeof output === 'string') return normalizePluginToolOutput(output);
24+
if (Array.isArray(output)) {
25+
if (!output.every(isPluginContentBlock)) {
26+
throw new Error('plugin tool result content blocks must be text or image blocks');
27+
}
28+
return normalizePluginToolOutput(output);
29+
}
30+
throw new Error('plugin tool result must be a string, content blocks, or undefined');
31+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
export function validateToolCallDecision(callDecision) {
2+
if (!callDecision || typeof callDecision !== 'object') {
3+
throw new Error('plugin tool.call result must be an object');
4+
}
5+
if (
6+
callDecision.action !== 'allow' &&
7+
callDecision.action !== 'reject-and-continue' &&
8+
callDecision.action !== 'modify' &&
9+
callDecision.action !== 'synthesize' &&
10+
callDecision.action !== 'error'
11+
) {
12+
throw new Error('plugin tool.call action must be allow, reject-and-continue, modify, synthesize, or error');
13+
}
14+
const allowedKeys = {
15+
allow: ['action'],
16+
'reject-and-continue': ['action', 'message'],
17+
modify: ['action', 'input'],
18+
synthesize: ['action', 'result'],
19+
error: ['action', 'message'],
20+
}[callDecision.action];
21+
if (Object.keys(callDecision).some((key) => !allowedKeys.includes(key))) {
22+
throw new Error('plugin tool.call fields must match the documented union');
23+
}
24+
}
25+
26+
export function applyToolCallDecision(toolName, request, callDecision = { action: 'allow' }) {
27+
validateToolCallDecision(callDecision);
28+
if (callDecision.action === 'allow') return { request };
29+
if (callDecision.action === 'reject-and-continue') {
30+
if (typeof callDecision.message !== 'string') {
31+
throw new Error('plugin tool.call reject-and-continue message must be a string');
32+
}
33+
return {
34+
output: {
35+
output: callDecision.message,
36+
exitCode: 0,
37+
},
38+
};
39+
}
40+
if (callDecision.action === 'synthesize') {
41+
const result = callDecision.result && isPlainObject(callDecision.result)
42+
? callDecision.result
43+
: callDecision;
44+
if (typeof result.output !== 'string') {
45+
throw new Error('plugin tool.call synthesize result.output must be a string');
46+
}
47+
if (result.exitCode !== undefined && !Number.isInteger(result.exitCode)) {
48+
throw new Error('plugin tool.call synthesize result.exitCode must be an integer');
49+
}
50+
return {
51+
output: {
52+
output: result.output.trimEnd(),
53+
exitCode: result.exitCode ?? 0,
54+
},
55+
};
56+
}
57+
if (callDecision.action === 'error') {
58+
if (typeof callDecision.message !== 'string') {
59+
throw new Error('plugin tool.call error message must be a string');
60+
}
61+
return {
62+
output: {
63+
output: callDecision.message,
64+
exitCode: 1,
65+
},
66+
};
67+
}
68+
if (callDecision.action === 'modify') {
69+
if (!isPlainObject(callDecision.input)) {
70+
throw new Error('plugin tool.call modify input must be an object');
71+
}
72+
return { request: { ...request, flags: callDecision.input } };
73+
}
74+
return { request };
75+
}
76+
77+
export function validateToolResultDecision(resultDecision = {}) {
78+
if (!resultDecision || typeof resultDecision !== 'object' || !Object.hasOwn(resultDecision, 'status')) return;
79+
if (resultDecision.status !== 'done' && resultDecision.status !== 'error' && resultDecision.status !== 'cancelled') {
80+
throw new Error('plugin tool.result status must be done, error, or cancelled');
81+
}
82+
const allowedKeys = resultDecision.status === 'done' ? ['output', 'status'] : ['error', 'output', 'status'];
83+
if (Object.keys(resultDecision).some((key) => !allowedKeys.includes(key))) {
84+
throw new Error('plugin tool.result fields must match the documented union');
85+
}
86+
if (
87+
(resultDecision.status === 'error' || resultDecision.status === 'cancelled') &&
88+
resultDecision.error !== undefined &&
89+
typeof resultDecision.error !== 'string'
90+
) {
91+
throw new Error('plugin tool.result error must be a string');
92+
}
93+
}
94+
95+
export function pluginToolResultDecisionOutput(resultDecision = {}, fallback) {
96+
validateToolResultDecision(resultDecision);
97+
if (resultDecision.status === 'error') {
98+
return resultDecision.error ?? resultDecision.output ?? fallback;
99+
}
100+
if (resultDecision.status === 'cancelled') {
101+
return resultDecision.error ?? resultDecision.output ?? 'Tool cancelled';
102+
}
103+
return resultDecision.output !== undefined ? resultDecision.output : fallback;
104+
}
105+
106+
export function pluginToolResultDecisionExitCode(resultDecision = {}, fallback = 0) {
107+
validateToolResultDecision(resultDecision);
108+
if (resultDecision.status === 'error' || resultDecision.status === 'cancelled') return 1;
109+
if (resultDecision.status === 'done') return 0;
110+
return fallback;
111+
}
112+
113+
function isPlainObject(value) {
114+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
115+
}

src/tools/builtin/runtime.mjs

Lines changed: 18 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
import { randomUUID } from 'node:crypto';
22
import path from 'node:path';
3+
import {
4+
pluginToolResultDecisionExitCode,
5+
pluginToolResultDecisionOutput,
6+
} from './runtime-decisions.mjs';
7+
8+
export {
9+
applyToolCallDecision,
10+
pluginToolResultDecisionExitCode,
11+
pluginToolResultDecisionOutput,
12+
validateToolCallDecision,
13+
validateToolResultDecision,
14+
} from './runtime-decisions.mjs';
15+
export {
16+
isPluginContentBlock,
17+
normalizePluginToolExecuteOutput,
18+
normalizePluginToolOutput,
19+
pluginContentBlockText,
20+
} from './runtime-content.mjs';
321

422
export function createToolUseID() {
523
return `toolu_${randomUUID()}`;
@@ -40,158 +58,10 @@ export function pluginToolUseBlock(tool, input, toolUseID) {
4058
};
4159
}
4260

43-
export function pluginToolResultDecisionOutput(resultDecision = {}, fallback) {
44-
validateToolResultDecision(resultDecision);
45-
if (resultDecision.status === 'error') {
46-
return resultDecision.error ?? resultDecision.output ?? fallback;
47-
}
48-
if (resultDecision.status === 'cancelled') {
49-
return resultDecision.error ?? resultDecision.output ?? 'Tool cancelled';
50-
}
51-
return resultDecision.output !== undefined ? resultDecision.output : fallback;
52-
}
53-
54-
export function pluginToolResultDecisionExitCode(resultDecision = {}, fallback = 0) {
55-
validateToolResultDecision(resultDecision);
56-
if (resultDecision.status === 'error' || resultDecision.status === 'cancelled') return 1;
57-
if (resultDecision.status === 'done') return 0;
58-
return fallback;
59-
}
60-
61-
export function validateToolResultDecision(resultDecision = {}) {
62-
if (!resultDecision || typeof resultDecision !== 'object' || !Object.hasOwn(resultDecision, 'status')) return;
63-
if (resultDecision.status !== 'done' && resultDecision.status !== 'error' && resultDecision.status !== 'cancelled') {
64-
throw new Error('plugin tool.result status must be done, error, or cancelled');
65-
}
66-
const allowedKeys = resultDecision.status === 'done' ? ['output', 'status'] : ['error', 'output', 'status'];
67-
if (Object.keys(resultDecision).some((key) => !allowedKeys.includes(key))) {
68-
throw new Error('plugin tool.result fields must match the documented union');
69-
}
70-
if (
71-
(resultDecision.status === 'error' || resultDecision.status === 'cancelled') &&
72-
resultDecision.error !== undefined &&
73-
typeof resultDecision.error !== 'string'
74-
) {
75-
throw new Error('plugin tool.result error must be a string');
76-
}
77-
}
78-
7961
export function toolResultContent(toolRun = {}) {
8062
return Object.hasOwn(toolRun, 'toolResultOutput') ? toolRun.toolResultOutput : toolRun.output;
8163
}
8264

83-
export function normalizePluginToolOutput(output) {
84-
if (Array.isArray(output) && output.every(isPluginContentBlock)) {
85-
const text = output.map(pluginContentBlockText).filter(Boolean).join('\n').trimEnd();
86-
return { raw: output, text };
87-
}
88-
const text = String(output ?? '').trimEnd();
89-
return { raw: text, text };
90-
}
91-
92-
export function normalizePluginToolExecuteOutput(output) {
93-
if (output === undefined || typeof output === 'string') return normalizePluginToolOutput(output);
94-
if (Array.isArray(output)) {
95-
if (!output.every(isPluginContentBlock)) {
96-
throw new Error('plugin tool result content blocks must be text or image blocks');
97-
}
98-
return normalizePluginToolOutput(output);
99-
}
100-
throw new Error('plugin tool result must be a string, content blocks, or undefined');
101-
}
102-
103-
export function isPluginContentBlock(block) {
104-
if (!block || typeof block !== 'object') return false;
105-
if (block.type === 'text') return typeof block.text === 'string';
106-
if (block.type === 'image') return typeof block.mimeType === 'string' && typeof block.data === 'string';
107-
return false;
108-
}
109-
110-
export function pluginContentBlockText(block) {
111-
if (block.type === 'text') return block.text;
112-
return '';
113-
}
114-
115-
export function applyToolCallDecision(toolName, request, callDecision = { action: 'allow' }) {
116-
validateToolCallDecision(callDecision);
117-
if (callDecision.action === 'allow') return { request };
118-
if (callDecision.action === 'reject-and-continue') {
119-
if (typeof callDecision.message !== 'string') {
120-
throw new Error('plugin tool.call reject-and-continue message must be a string');
121-
}
122-
return {
123-
output: {
124-
output: callDecision.message,
125-
exitCode: 0,
126-
},
127-
};
128-
}
129-
if (callDecision.action === 'synthesize') {
130-
const result = callDecision.result && isPlainObject(callDecision.result)
131-
? callDecision.result
132-
: callDecision;
133-
if (typeof result.output !== 'string') {
134-
throw new Error('plugin tool.call synthesize result.output must be a string');
135-
}
136-
if (result.exitCode !== undefined && !Number.isInteger(result.exitCode)) {
137-
throw new Error('plugin tool.call synthesize result.exitCode must be an integer');
138-
}
139-
return {
140-
output: {
141-
output: result.output.trimEnd(),
142-
exitCode: result.exitCode ?? 0,
143-
},
144-
};
145-
}
146-
if (callDecision.action === 'error') {
147-
if (typeof callDecision.message !== 'string') {
148-
throw new Error('plugin tool.call error message must be a string');
149-
}
150-
return {
151-
output: {
152-
output: callDecision.message,
153-
exitCode: 1,
154-
},
155-
};
156-
}
157-
if (callDecision.action === 'modify') {
158-
if (!isPlainObject(callDecision.input)) {
159-
throw new Error('plugin tool.call modify input must be an object');
160-
}
161-
return { request: { ...request, flags: callDecision.input } };
162-
}
163-
return { request };
164-
}
165-
166-
export function validateToolCallDecision(callDecision) {
167-
if (!callDecision || typeof callDecision !== 'object') {
168-
throw new Error('plugin tool.call result must be an object');
169-
}
170-
if (
171-
callDecision.action !== 'allow' &&
172-
callDecision.action !== 'reject-and-continue' &&
173-
callDecision.action !== 'modify' &&
174-
callDecision.action !== 'synthesize' &&
175-
callDecision.action !== 'error'
176-
) {
177-
throw new Error('plugin tool.call action must be allow, reject-and-continue, modify, synthesize, or error');
178-
}
179-
const allowedKeys = {
180-
allow: ['action'],
181-
'reject-and-continue': ['action', 'message'],
182-
modify: ['action', 'input'],
183-
synthesize: ['action', 'result'],
184-
error: ['action', 'message'],
185-
}[callDecision.action];
186-
if (Object.keys(callDecision).some((key) => !allowedKeys.includes(key))) {
187-
throw new Error('plugin tool.call fields must match the documented union');
188-
}
189-
}
190-
191-
export function isPlainObject(value) {
192-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
193-
}
194-
19565
export function toolCallDecisionToolRun(toolName, input, toolUseID, callResult) {
19666
return {
19767
...callResult.output,

0 commit comments

Comments
 (0)