@cspeach/cli 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell_exec — Phase 3 / Chunk 3B shell tool.
|
|
3
|
+
*
|
|
4
|
+
* Runs a hardcoded safelist of build / VCS commands via argv arrays —
|
|
5
|
+
* NO shell parser ever involved. Default safelist: npm, pnpm, ui5,
|
|
6
|
+
* cds, git, abaplint, node. User extends via the
|
|
7
|
+
* [shell_exec]
|
|
8
|
+
* allow = ["docker", "make"]
|
|
9
|
+
* block in ~/.cspeach/config.toml.
|
|
10
|
+
*
|
|
11
|
+
* Cross-platform: on Windows resolves the .cmd / .bat / .exe shim
|
|
12
|
+
* via PATH walk before spawning (Node's spawn with shell:false can't
|
|
13
|
+
* directly invoke .cmd files; using shell:true reopens shell-injection
|
|
14
|
+
* + CVE-2024-27980). On Unix the bare name is passed straight through
|
|
15
|
+
* — spawn finds it on PATH.
|
|
16
|
+
*
|
|
17
|
+
* Operational caps:
|
|
18
|
+
* - 30 s wall-clock timeout (overridable up to 300 s via timeout_ms arg)
|
|
19
|
+
* - 1 MB per-stream output cap (stdout + stderr each)
|
|
20
|
+
* - cwd resolved through resolveSafePath — no escape
|
|
21
|
+
* - env scrubbed: only PATH, HOME, USERPROFILE, TEMP, TMP, SystemRoot,
|
|
22
|
+
* windir, Path pass through. CSPEACH_*, ANTHROPIC_*, SAP_* never leak.
|
|
23
|
+
*
|
|
24
|
+
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_SHELL_EXEC=on.
|
|
25
|
+
* isMutating: true — hooks into the existing approval gate.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
import { promises as fs } from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import { registerTool } from '../index.js';
|
|
31
|
+
import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
|
|
32
|
+
import { loadSafelist, buildSafeEnv, resolveExecutable, validateArgv, rejectPathSeparator, DEFAULT_SAFELIST, KILL_GRACE_MS, } from '../_command-shared.js';
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
34
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
35
|
+
const MAX_OUTPUT_BYTES = 1_000_000; // per stream
|
|
36
|
+
export async function shellExecHandler(args, ctx) {
|
|
37
|
+
if (!args.command || typeof args.command !== 'string') {
|
|
38
|
+
return { content: 'error: command is required (non-empty string)', is_error: true };
|
|
39
|
+
}
|
|
40
|
+
if (args.cwd !== undefined && typeof args.cwd !== 'string') {
|
|
41
|
+
return { content: 'error: cwd must be a string when provided', is_error: true };
|
|
42
|
+
}
|
|
43
|
+
if (args.timeout_ms !== undefined && typeof args.timeout_ms !== 'number') {
|
|
44
|
+
return { content: 'error: timeout_ms must be a number when provided', is_error: true };
|
|
45
|
+
}
|
|
46
|
+
const argv = Array.isArray(args.args) ? args.args.filter((a) => typeof a === 'string') : [];
|
|
47
|
+
const argvErr = validateArgv(argv);
|
|
48
|
+
if (argvErr !== null)
|
|
49
|
+
return { content: `error: ${argvErr}`, is_error: true };
|
|
50
|
+
// Reject absolute paths and any name containing a path separator. This
|
|
51
|
+
// ensures the safelist check is meaningful — the model can't pass
|
|
52
|
+
// /bin/sh or C:\Windows\System32\cmd.exe just because its basename is
|
|
53
|
+
// safelisted (and it isn't, but the rule is "bare name only").
|
|
54
|
+
const sepErr = rejectPathSeparator(args.command);
|
|
55
|
+
if (sepErr !== null)
|
|
56
|
+
return { content: `error: ${sepErr}`, is_error: true };
|
|
57
|
+
const safelist = await loadSafelist();
|
|
58
|
+
if (!safelist.has(args.command)) {
|
|
59
|
+
return {
|
|
60
|
+
content: `error: command "${args.command}" is not in the safelist. allowed: ${[...safelist].sort().join(', ')}. extend via [shell_exec] allow = [...] in ~/.cspeach/config.toml.`,
|
|
61
|
+
is_error: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
let cwd;
|
|
65
|
+
try {
|
|
66
|
+
cwd = args.cwd ? resolveSafePath(ctx.cwd, args.cwd) : path.resolve(ctx.cwd);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof PathOutsideRootError) {
|
|
70
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
71
|
+
}
|
|
72
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const stat = await fs.stat(cwd);
|
|
76
|
+
if (!stat.isDirectory()) {
|
|
77
|
+
return { content: `error: cwd "${args.cwd ?? '.'}" is not a directory`, is_error: true };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { content: `error: cwd "${args.cwd ?? '.'}" does not exist`, is_error: true };
|
|
82
|
+
}
|
|
83
|
+
const resolved = await resolveExecutable(args.command);
|
|
84
|
+
if (resolved === null) {
|
|
85
|
+
return {
|
|
86
|
+
content: `error: command "${args.command}" not installed on this system (not found on PATH).`,
|
|
87
|
+
is_error: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const timeoutMs = Math.min(Math.max(typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : DEFAULT_TIMEOUT_MS, 1_000), MAX_TIMEOUT_MS);
|
|
91
|
+
return await new Promise((resolve) => {
|
|
92
|
+
let stdoutBuf = '';
|
|
93
|
+
let stderrBuf = '';
|
|
94
|
+
let stdoutTrunc = false;
|
|
95
|
+
let stderrTrunc = false;
|
|
96
|
+
let settled = false;
|
|
97
|
+
let timedOut = false;
|
|
98
|
+
const settle = (result) => {
|
|
99
|
+
if (settled)
|
|
100
|
+
return;
|
|
101
|
+
settled = true;
|
|
102
|
+
resolve(result);
|
|
103
|
+
};
|
|
104
|
+
const child = spawn(resolved, argv, {
|
|
105
|
+
cwd,
|
|
106
|
+
env: buildSafeEnv(),
|
|
107
|
+
windowsHide: true,
|
|
108
|
+
shell: false,
|
|
109
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
});
|
|
111
|
+
child.stdout?.on('data', (chunk) => {
|
|
112
|
+
const remaining = MAX_OUTPUT_BYTES - stdoutBuf.length;
|
|
113
|
+
if (remaining <= 0) {
|
|
114
|
+
stdoutTrunc = true;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const text = chunk.toString('utf-8');
|
|
118
|
+
if (text.length <= remaining)
|
|
119
|
+
stdoutBuf += text;
|
|
120
|
+
else {
|
|
121
|
+
stdoutBuf += text.slice(0, remaining);
|
|
122
|
+
stdoutTrunc = true;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
child.stderr?.on('data', (chunk) => {
|
|
126
|
+
const remaining = MAX_OUTPUT_BYTES - stderrBuf.length;
|
|
127
|
+
if (remaining <= 0) {
|
|
128
|
+
stderrTrunc = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const text = chunk.toString('utf-8');
|
|
132
|
+
if (text.length <= remaining)
|
|
133
|
+
stderrBuf += text;
|
|
134
|
+
else {
|
|
135
|
+
stderrBuf += text.slice(0, remaining);
|
|
136
|
+
stderrTrunc = true;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
timedOut = true;
|
|
141
|
+
try {
|
|
142
|
+
child.kill('SIGTERM');
|
|
143
|
+
}
|
|
144
|
+
catch { /* ignore */ }
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
try {
|
|
147
|
+
child.kill('SIGKILL');
|
|
148
|
+
}
|
|
149
|
+
catch { /* ignore */ }
|
|
150
|
+
}, KILL_GRACE_MS).unref();
|
|
151
|
+
}, timeoutMs);
|
|
152
|
+
timer.unref();
|
|
153
|
+
child.on('error', (err) => {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
settle({ content: `error: spawn failed — ${err.message}`, is_error: true });
|
|
156
|
+
});
|
|
157
|
+
child.on('close', (code, signal) => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
if (timedOut) {
|
|
160
|
+
const tail = (stdoutBuf ? `stdout (partial):\n${stdoutBuf}\n${stdoutTrunc ? '[truncated]\n' : ''}` : '') +
|
|
161
|
+
(stderrBuf ? `stderr (partial):\n${stderrBuf}\n${stderrTrunc ? '[truncated]\n' : ''}` : '');
|
|
162
|
+
settle({
|
|
163
|
+
content: `error: command timed out after ${timeoutMs} ms — child killed.\n${tail}`,
|
|
164
|
+
is_error: true,
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const stdoutSection = `stdout:\n${stdoutBuf}${stdoutTrunc ? '\n[truncated — exceeded 1 MB cap]' : ''}`;
|
|
169
|
+
const stderrSection = `stderr:\n${stderrBuf}${stderrTrunc ? '\n[truncated — exceeded 1 MB cap]' : ''}`;
|
|
170
|
+
const exitSection = `exit code: ${code ?? 'null'}${signal ? ` (signal: ${signal})` : ''}`;
|
|
171
|
+
settle({ content: `${stdoutSection}\n\n${stderrSection}\n\n${exitSection}` });
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
registerTool({
|
|
176
|
+
name: 'shell_exec',
|
|
177
|
+
description: 'Run a safelisted shell command (argv only — no shell parser). Default safelist: ' +
|
|
178
|
+
[...DEFAULT_SAFELIST].sort().join(', ') +
|
|
179
|
+
'. User extends via [shell_exec] allow = [...] in ~/.cspeach/config.toml. ' +
|
|
180
|
+
'30s default timeout (overridable up to 300s), 1 MB per-stream output cap. ' +
|
|
181
|
+
'Returns stdout, stderr, and exit code in separate sections.',
|
|
182
|
+
isMutating: true,
|
|
183
|
+
category: 'shell',
|
|
184
|
+
flagGated: true,
|
|
185
|
+
input_schema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: {
|
|
188
|
+
command: {
|
|
189
|
+
type: 'string',
|
|
190
|
+
description: 'Bare command name (e.g. "npm", "git"). No paths. No absolute paths. Must appear in the safelist.',
|
|
191
|
+
},
|
|
192
|
+
args: {
|
|
193
|
+
type: 'array',
|
|
194
|
+
items: { type: 'string' },
|
|
195
|
+
description: 'argv array passed literally to the child. No shell parsing — metacharacters are inert.',
|
|
196
|
+
},
|
|
197
|
+
cwd: {
|
|
198
|
+
type: 'string',
|
|
199
|
+
description: 'Optional working directory relative to project root. Must resolve inside the project.',
|
|
200
|
+
},
|
|
201
|
+
timeout_ms: {
|
|
202
|
+
type: 'number',
|
|
203
|
+
description: 'Wall-clock timeout in ms. Default 30000, min 1000, max 300000.',
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
required: ['command'],
|
|
207
|
+
},
|
|
208
|
+
handler: shellExecHandler,
|
|
209
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot tools + autoSnapshot helper.
|
|
3
|
+
*
|
|
4
|
+
* Four registered tools (all isMutating: false — local-FS operations):
|
|
5
|
+
* sap_snapshot_take — take a snapshot before write (or on-demand)
|
|
6
|
+
* sap_snapshot_list — list snapshots for a type/name (or all)
|
|
7
|
+
* sap_snapshot_read — read raw source from a specific snapshot
|
|
8
|
+
* sap_snapshot_cleanup — delete snapshots older than retentionHours
|
|
9
|
+
*
|
|
10
|
+
* Confirmed snapshots signatures (sap-client/src/safety/snapshots.ts):
|
|
11
|
+
* take(type, name, source, reason?) → Promise<SnapshotEntry>
|
|
12
|
+
* list(type?, name?) → Promise<SnapshotEntry[]>
|
|
13
|
+
* read(type, name, snapshotId) → Promise<string>
|
|
14
|
+
* cleanup(retentionHours?) → Promise<number>
|
|
15
|
+
*/
|
|
16
|
+
import { registerTool } from './index.js';
|
|
17
|
+
import { snapshots } from '@cspeach/sap-client';
|
|
18
|
+
registerTool({
|
|
19
|
+
name: 'sap_snapshot_take',
|
|
20
|
+
description: "Take a snapshot of an object's current source before modification. Usually invoked automatically by the CLI before any write — rarely called directly by the model.",
|
|
21
|
+
isMutating: false,
|
|
22
|
+
input_schema: {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
name: { type: 'string', description: 'ABAP object name' },
|
|
26
|
+
type: { type: 'string', description: 'ABAP object type (PROG, CLAS, etc.)' },
|
|
27
|
+
},
|
|
28
|
+
required: ['name', 'type'],
|
|
29
|
+
},
|
|
30
|
+
handler: async (args, ctx) => {
|
|
31
|
+
const source = await ctx.adt.getSource(args.type, args.name).catch(() => null);
|
|
32
|
+
if (source === null) {
|
|
33
|
+
return { content: JSON.stringify({ skipped: true, reason: 'object_not_found' }) };
|
|
34
|
+
}
|
|
35
|
+
const entry = await snapshots.take(args.type, args.name, source, 'manual');
|
|
36
|
+
return { content: JSON.stringify({ snapshot: entry, size: source.length }) };
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Auto-snapshot helper called by write tools before mutating an object.
|
|
41
|
+
* Silently skips if the object does not exist yet (first write after create).
|
|
42
|
+
*
|
|
43
|
+
* snapshots.take expects (type, name, source, reason).
|
|
44
|
+
*/
|
|
45
|
+
export async function autoSnapshot(objectName, objectType, adt) {
|
|
46
|
+
const src = await adt.getSource(objectType, objectName).catch(() => null);
|
|
47
|
+
if (src === null)
|
|
48
|
+
return; // new object — nothing to snapshot
|
|
49
|
+
await snapshots.take(objectType, objectName, src, 'before_write');
|
|
50
|
+
}
|
|
51
|
+
// ── sap_snapshot_list ─────────────────────────────────────────────────────────
|
|
52
|
+
registerTool({
|
|
53
|
+
name: 'sap_snapshot_list',
|
|
54
|
+
description: 'List available snapshots. Pass type and name to filter by object; omit both to list all snapshots.',
|
|
55
|
+
isMutating: false,
|
|
56
|
+
input_schema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
type: { type: 'string', description: 'ABAP object type filter (optional)' },
|
|
60
|
+
name: { type: 'string', description: 'ABAP object name filter (optional)' },
|
|
61
|
+
},
|
|
62
|
+
required: [],
|
|
63
|
+
},
|
|
64
|
+
handler: async (args, _ctx) => {
|
|
65
|
+
const entries = await snapshots.list(args.type, args.name);
|
|
66
|
+
return { content: JSON.stringify(entries) };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
// ── sap_snapshot_read ─────────────────────────────────────────────────────────
|
|
70
|
+
registerTool({
|
|
71
|
+
name: 'sap_snapshot_read',
|
|
72
|
+
description: 'Read the raw source text from a specific snapshot. Use sap_snapshot_list first to find the snapshotId.',
|
|
73
|
+
isMutating: false,
|
|
74
|
+
input_schema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
type: { type: 'string', description: 'ABAP object type (PROG, CLAS, etc.)' },
|
|
78
|
+
name: { type: 'string', description: 'ABAP object name' },
|
|
79
|
+
snapshotId: { type: 'string', description: 'Snapshot file id from sap_snapshot_list' },
|
|
80
|
+
},
|
|
81
|
+
required: ['type', 'name', 'snapshotId'],
|
|
82
|
+
},
|
|
83
|
+
handler: async (args, _ctx) => {
|
|
84
|
+
const source = await snapshots.read(args.type, args.name, args.snapshotId);
|
|
85
|
+
return { content: source };
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
// ── sap_snapshot_cleanup ──────────────────────────────────────────────────────
|
|
89
|
+
registerTool({
|
|
90
|
+
name: 'sap_snapshot_cleanup',
|
|
91
|
+
description: 'Delete snapshots older than retentionHours (default 168 = 7 days). Returns count of deleted snapshots.',
|
|
92
|
+
isMutating: false,
|
|
93
|
+
input_schema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
retentionHours: {
|
|
97
|
+
type: 'number',
|
|
98
|
+
description: 'Delete snapshots older than this many hours (default: 168)',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
required: [],
|
|
102
|
+
},
|
|
103
|
+
handler: async (args, _ctx) => {
|
|
104
|
+
const count = await snapshots.cleanup(args.retentionHours);
|
|
105
|
+
return { content: JSON.stringify({ deleted: count }) };
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/subagent/_background-shared.ts
|
|
2
|
+
/**
|
|
3
|
+
* Phase 3 / Chunk 3E — in-memory background-process registry.
|
|
4
|
+
*
|
|
5
|
+
* background_run writes here; monitor_emit reads + consumes from here.
|
|
6
|
+
*
|
|
7
|
+
* Per-bg ring buffer with FIFO eviction at MAX_EVENTS_PER_BG. Each
|
|
8
|
+
* consume call reports a `dropped` counter when events were evicted
|
|
9
|
+
* before being read; the counter resets on consume so the agent can
|
|
10
|
+
* tell "events were lost since my last call" without per-call drift.
|
|
11
|
+
*
|
|
12
|
+
* Per-process only — never persisted. Two CLI instances on the same
|
|
13
|
+
* machine each have their own registry; no IPC. This is deliberate:
|
|
14
|
+
* the lifetime of a bg_id is bounded by the CLI process it was
|
|
15
|
+
* spawned in.
|
|
16
|
+
*
|
|
17
|
+
* Exit handler installed exactly once via installExitHandler(). When
|
|
18
|
+
* Node exits (normal or signal), every tracked child is SIGTERMd, then
|
|
19
|
+
* SIGKILLd after KILL_GRACE_MS ms. Both timers .unref() so they don't
|
|
20
|
+
* keep Node alive past the legitimate shutdown.
|
|
21
|
+
*/
|
|
22
|
+
import { KILL_GRACE_MS } from '../_command-shared.js';
|
|
23
|
+
export const MAX_EVENTS_PER_BG = 10_000;
|
|
24
|
+
const registry = new Map();
|
|
25
|
+
let exitHandlerInstalled = false;
|
|
26
|
+
export function registerBackground(p) {
|
|
27
|
+
const entry = {
|
|
28
|
+
bg_id: p.bg_id,
|
|
29
|
+
command: p.command,
|
|
30
|
+
argv: p.argv,
|
|
31
|
+
child: p.child,
|
|
32
|
+
startedAt: Date.now(),
|
|
33
|
+
closed: false,
|
|
34
|
+
exitCode: null,
|
|
35
|
+
signal: null,
|
|
36
|
+
events: [],
|
|
37
|
+
pendingDropped: 0,
|
|
38
|
+
};
|
|
39
|
+
registry.set(p.bg_id, entry);
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
export function getBackground(bg_id) {
|
|
43
|
+
return registry.get(bg_id);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Append an event to the bg_id's ring buffer. FIFO-evicts the oldest
|
|
47
|
+
* entry once length reaches MAX_EVENTS_PER_BG; the eviction count is
|
|
48
|
+
* surfaced via ConsumeResult.dropped on the next consumeEvents() call.
|
|
49
|
+
*
|
|
50
|
+
* No-op if the bg_id is unknown (the consumer cleared the entry between
|
|
51
|
+
* the child's emission and this call). The dropped data is accepted as
|
|
52
|
+
* lost — spec contract, not a bug.
|
|
53
|
+
*/
|
|
54
|
+
export function recordEvent(bg_id, ev) {
|
|
55
|
+
const entry = registry.get(bg_id);
|
|
56
|
+
if (!entry)
|
|
57
|
+
return; // race: child wrote after consumeAndClear; silently drop
|
|
58
|
+
if (entry.events.length >= MAX_EVENTS_PER_BG) {
|
|
59
|
+
// We push AFTER the eviction, so free one extra slot to land at exactly MAX.
|
|
60
|
+
const drop = entry.events.length - MAX_EVENTS_PER_BG + 1;
|
|
61
|
+
entry.events.splice(0, drop);
|
|
62
|
+
entry.pendingDropped += drop;
|
|
63
|
+
}
|
|
64
|
+
entry.events.push(ev);
|
|
65
|
+
if (ev.kind === 'exit' && !entry.closed) {
|
|
66
|
+
entry.closed = true;
|
|
67
|
+
entry.exitCode = ev.exitCode ?? null;
|
|
68
|
+
entry.signal = ev.signal ?? null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function consumeEvents(bg_id, max) {
|
|
72
|
+
const entry = registry.get(bg_id);
|
|
73
|
+
if (!entry)
|
|
74
|
+
return null;
|
|
75
|
+
const events = entry.events.splice(0, max);
|
|
76
|
+
const dropped = entry.pendingDropped;
|
|
77
|
+
entry.pendingDropped = 0;
|
|
78
|
+
return {
|
|
79
|
+
events,
|
|
80
|
+
dropped,
|
|
81
|
+
closed: entry.closed,
|
|
82
|
+
exitCode: entry.exitCode,
|
|
83
|
+
signal: entry.signal,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Idempotent. Subsequent calls are silent no-ops. */
|
|
87
|
+
export function installExitHandler() {
|
|
88
|
+
if (exitHandlerInstalled)
|
|
89
|
+
return;
|
|
90
|
+
exitHandlerInstalled = true;
|
|
91
|
+
// 'exit' fires synchronously during Node shutdown. Best-effort SIGTERM
|
|
92
|
+
// each tracked child; the SIGKILL escalation timer cannot fire from
|
|
93
|
+
// inside an 'exit' handler (event-loop is shutting down) so we
|
|
94
|
+
// additionally hook SIGINT/SIGTERM/SIGHUP below for graceful paths
|
|
95
|
+
// where the loop is still alive.
|
|
96
|
+
process.on('exit', () => {
|
|
97
|
+
for (const entry of registry.values()) {
|
|
98
|
+
if (!entry.closed) {
|
|
99
|
+
try {
|
|
100
|
+
entry.child.kill('SIGTERM');
|
|
101
|
+
}
|
|
102
|
+
catch { /* ignore */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
107
|
+
process.on(sig, () => {
|
|
108
|
+
for (const entry of registry.values()) {
|
|
109
|
+
if (!entry.closed) {
|
|
110
|
+
try {
|
|
111
|
+
entry.child.kill('SIGTERM');
|
|
112
|
+
}
|
|
113
|
+
catch { /* ignore */ }
|
|
114
|
+
const t = setTimeout(() => {
|
|
115
|
+
try {
|
|
116
|
+
entry.child.kill('SIGKILL');
|
|
117
|
+
}
|
|
118
|
+
catch { /* ignore */ }
|
|
119
|
+
}, KILL_GRACE_MS);
|
|
120
|
+
t.unref();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Test-only — clears registry between cases. Does NOT uninstall the exit handler (handler is process-lifetime-scoped). */
|
|
127
|
+
export function __resetRegistryForTests() {
|
|
128
|
+
registry.clear();
|
|
129
|
+
}
|
|
130
|
+
/** Test-only — observe whether installExitHandler has fired. */
|
|
131
|
+
export function __isExitHandlerInstalledForTests() {
|
|
132
|
+
return exitHandlerInstalled;
|
|
133
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/subagent/agent_run.ts
|
|
2
|
+
/**
|
|
3
|
+
* agent_run — Phase 3 / Chunk 3E.
|
|
4
|
+
*
|
|
5
|
+
* Dispatches a sub-task to a fresh runTurn invocation with an isolated
|
|
6
|
+
* SessionState. Same process; isolated session — matches Decision 4
|
|
7
|
+
* (RESOLVED 2026-05-10) and Claude Code's Agent-tool semantics.
|
|
8
|
+
*
|
|
9
|
+
* Guards:
|
|
10
|
+
* - CSPEACH_AGENT_DEPTH env var enforces a hard depth cap of
|
|
11
|
+
* MAX_AGENT_DEPTH=3. Up to 3 nested agent_run invocations are
|
|
12
|
+
* allowed in a single recursion chain (parent dispatches child at
|
|
13
|
+
* depth 0→1, child can dispatch grandchild at 1→2, grandchild can
|
|
14
|
+
* dispatch great-grandchild at 2→3; the next call at depth 3 is
|
|
15
|
+
* refused). Bounds token cost in the worst case.
|
|
16
|
+
* - Per-call max_tokens default DEFAULT_MAX_TOKENS=100_000, hard
|
|
17
|
+
* cap HARD_MAX_TOKENS=200_000.
|
|
18
|
+
* - `skill` arg is REQUIRED; no free-form subagent.
|
|
19
|
+
* - `previewHook` is intentionally absent from the child ctx —
|
|
20
|
+
* subagent cannot auto-approve writes.
|
|
21
|
+
* - Subagent stdout is suppressed via a null-sink EventEmitter —
|
|
22
|
+
* subagent text is captured into the return envelope, but doesn't
|
|
23
|
+
* pollute the parent's REPL.
|
|
24
|
+
*
|
|
25
|
+
* Flag-gated: invisible to listTools() unless
|
|
26
|
+
* CSPEACH_TOOL_AGENT_RUN=on. isMutating: false (the subagent ITSELF may
|
|
27
|
+
* call mutating tools — but those go through their own approval gates).
|
|
28
|
+
*/
|
|
29
|
+
import { EventEmitter } from 'node:events';
|
|
30
|
+
import { registerTool } from '../index.js';
|
|
31
|
+
import { runTurn } from '../../agent/loop.js';
|
|
32
|
+
import { collectTurnAssistantText } from '../../agent/turn-assistant-text.js';
|
|
33
|
+
export const MAX_AGENT_DEPTH = 3;
|
|
34
|
+
export const DEFAULT_MAX_TOKENS = 100_000;
|
|
35
|
+
export const HARD_MAX_TOKENS = 200_000;
|
|
36
|
+
// Same NAME_PATTERN shape playbook_get uses — alphanumerics + `:_-`, ≤64.
|
|
37
|
+
const SKILL_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9:_-]{0,63}$/;
|
|
38
|
+
export async function agentRunHandler(args, ctx) {
|
|
39
|
+
if (!args.skill || typeof args.skill !== 'string' || args.skill.trim().length === 0) {
|
|
40
|
+
return { content: "error: 'skill' is required (non-empty string).", is_error: true };
|
|
41
|
+
}
|
|
42
|
+
if (!SKILL_NAME_PATTERN.test(args.skill)) {
|
|
43
|
+
return { content: `error: invalid skill name '${args.skill}'. Names must match ${SKILL_NAME_PATTERN.source}.`, is_error: true };
|
|
44
|
+
}
|
|
45
|
+
if (!args.task || typeof args.task !== 'string' || args.task.trim().length === 0) {
|
|
46
|
+
return { content: "error: 'task' is required (non-empty string).", is_error: true };
|
|
47
|
+
}
|
|
48
|
+
if (!ctx.provider) {
|
|
49
|
+
return {
|
|
50
|
+
content: 'error: agent_run requires ctx.provider — not wired into this ToolContext. ' +
|
|
51
|
+
'This is a CSPeach internal wiring issue; the REPL and one-shot entry points provide one automatically.',
|
|
52
|
+
is_error: true,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!ctx.skillSource) {
|
|
56
|
+
return {
|
|
57
|
+
content: 'error: agent_run requires ctx.skillSource — not wired into this ToolContext. ' +
|
|
58
|
+
'This is a CSPeach internal wiring issue; the REPL and one-shot entry points provide one automatically.',
|
|
59
|
+
is_error: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Depth guard — read BEFORE incrementing.
|
|
63
|
+
const currentDepth = Number.parseInt(process.env.CSPEACH_AGENT_DEPTH ?? '0', 10);
|
|
64
|
+
const depth = Number.isFinite(currentDepth) && currentDepth >= 0 ? currentDepth : 0;
|
|
65
|
+
if (depth >= MAX_AGENT_DEPTH) {
|
|
66
|
+
return {
|
|
67
|
+
content: `error: subagent depth cap reached (${depth} >= ${MAX_AGENT_DEPTH}). ` +
|
|
68
|
+
`Refusing further recursion to bound token cost. The parent agent should ` +
|
|
69
|
+
`complete this task itself rather than nest another subagent.`,
|
|
70
|
+
is_error: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Token cap.
|
|
74
|
+
const maxTokensFloored = typeof args.max_tokens === 'number' && Number.isFinite(args.max_tokens)
|
|
75
|
+
? Math.floor(args.max_tokens)
|
|
76
|
+
: 0;
|
|
77
|
+
const maxTokensRaw = maxTokensFloored > 0 ? maxTokensFloored : DEFAULT_MAX_TOKENS;
|
|
78
|
+
const maxTokensOverride = Math.min(maxTokensRaw, HARD_MAX_TOKENS);
|
|
79
|
+
// Build an isolated child SessionState. Fresh empty messages array;
|
|
80
|
+
// fresh session.id so saveSession (called by runTurn after each tool
|
|
81
|
+
// result) cannot collide with the parent's session file.
|
|
82
|
+
// `as any` casts: SessionState has many fields the child doesn't strictly
|
|
83
|
+
// need; spreading from parent + overriding id/messages/toolCalls/usage is
|
|
84
|
+
// the smallest reasonable contract. TypeScript can't express "subset of
|
|
85
|
+
// SessionState that runTurn actually reads" cleanly.
|
|
86
|
+
const childSession = {
|
|
87
|
+
...ctx.session,
|
|
88
|
+
id: `subagent-${Date.now()}-${Math.floor(Math.random() * 1e9).toString(16)}`,
|
|
89
|
+
messages: [],
|
|
90
|
+
toolCalls: [],
|
|
91
|
+
// I1 fix: clone usage so child mutations don't inflate parent counters.
|
|
92
|
+
// loop.ts mutates session.usage during the LLM stream; a shared reference
|
|
93
|
+
// would corrupt the parent's per-turn delta calculations.
|
|
94
|
+
usage: ctx.session?.usage
|
|
95
|
+
? { ...ctx.session.usage }
|
|
96
|
+
: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 },
|
|
97
|
+
// Cosmetic: child's started_at should reflect when it actually started,
|
|
98
|
+
// not parent's start time. Avoids surprising file timestamps.
|
|
99
|
+
started_at: new Date().toISOString(),
|
|
100
|
+
last_turn_at: new Date().toISOString(),
|
|
101
|
+
};
|
|
102
|
+
// Build an isolated child ctx. Crucial differences vs parent ctx:
|
|
103
|
+
// - session: the fresh child session above (independent usage counters)
|
|
104
|
+
// - previewHook: undefined — subagent must NOT auto-approve writes
|
|
105
|
+
// - pendingDispatch / currentTransport: cleared — subagent doesn't
|
|
106
|
+
// mutate parent REPL state
|
|
107
|
+
// chunkEmitter is passed via the RunTurn params (not the ctx) as a
|
|
108
|
+
// null-sink EventEmitter — see I2 fix below.
|
|
109
|
+
const childCtx = {
|
|
110
|
+
adt: ctx.adt,
|
|
111
|
+
sapAlias: ctx.sapAlias,
|
|
112
|
+
session: childSession,
|
|
113
|
+
cwd: ctx.cwd,
|
|
114
|
+
provider: ctx.provider,
|
|
115
|
+
skillSource: ctx.skillSource,
|
|
116
|
+
// previewHook + pendingDispatch + currentTransport intentionally omitted.
|
|
117
|
+
};
|
|
118
|
+
// I2 fix: a null-sink EventEmitter suppresses all subagent output. Every
|
|
119
|
+
// stdout-write site in loop.ts is guarded by `if (params.chunkEmitter)`,
|
|
120
|
+
// and emit() to a listener-less emitter is a no-op. Result: subagent text
|
|
121
|
+
// flows into childSession.messages (where collectTurnAssistantText reads it)
|
|
122
|
+
// but never reaches the user's terminal or the parent's Ink renderer.
|
|
123
|
+
const nullSinkEmitter = new EventEmitter();
|
|
124
|
+
// Increment depth env var BEFORE the child runs; restore after.
|
|
125
|
+
const priorDepth = process.env.CSPEACH_AGENT_DEPTH;
|
|
126
|
+
process.env.CSPEACH_AGENT_DEPTH = String(depth + 1);
|
|
127
|
+
try {
|
|
128
|
+
await runTurn({
|
|
129
|
+
provider: ctx.provider,
|
|
130
|
+
userMessage: args.task,
|
|
131
|
+
skill: args.skill,
|
|
132
|
+
ctx: childCtx,
|
|
133
|
+
chunkEmitter: nullSinkEmitter,
|
|
134
|
+
maxTokensOverride,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
139
|
+
return { content: `error: subagent failed — ${msg}`, is_error: true };
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
if (priorDepth === undefined)
|
|
143
|
+
delete process.env.CSPEACH_AGENT_DEPTH;
|
|
144
|
+
else
|
|
145
|
+
process.env.CSPEACH_AGENT_DEPTH = priorDepth;
|
|
146
|
+
}
|
|
147
|
+
const childText = collectTurnAssistantText(childSession.messages, 0);
|
|
148
|
+
if (childText.trim().length === 0) {
|
|
149
|
+
return {
|
|
150
|
+
content: 'subagent completed without emitting assistant text (no useful answer to return).',
|
|
151
|
+
is_error: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { content: childText };
|
|
155
|
+
}
|
|
156
|
+
registerTool({
|
|
157
|
+
name: 'agent_run',
|
|
158
|
+
description: 'Dispatch a focused sub-task to a fresh subagent loop in the SAME process with an ISOLATED ' +
|
|
159
|
+
'session. The child runs the named skill against the given task and returns its final ' +
|
|
160
|
+
'assistant text. Depth-capped at 3 levels; default max_tokens 100000 (cap 200000). The ' +
|
|
161
|
+
'subagent inherits adt + skillSource + provider but NOT previewHook — subagent cannot ' +
|
|
162
|
+
'auto-approve mutating writes. A null-sink emitter is passed for chunkEmitter so subagent ' +
|
|
163
|
+
'output stays out of the parent UI.',
|
|
164
|
+
isMutating: false,
|
|
165
|
+
category: 'subagent',
|
|
166
|
+
flagGated: true,
|
|
167
|
+
input_schema: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
skill: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
description: 'Required. Skill name the subagent runs in (e.g. "abap-review"). No leading slash.',
|
|
173
|
+
},
|
|
174
|
+
task: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
description: 'Required. The user-message-equivalent the subagent receives.',
|
|
177
|
+
},
|
|
178
|
+
max_tokens: {
|
|
179
|
+
type: 'number',
|
|
180
|
+
description: 'Optional per-call cap. Default 100000, hard cap 200000.',
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
required: ['skill', 'task'],
|
|
184
|
+
},
|
|
185
|
+
handler: (args, ctx) => agentRunHandler(args, ctx),
|
|
186
|
+
});
|