@zergai/ztc 0.3.0-beta.1 → 0.3.1-dev.36465749722
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/bin/zmux-darwin-arm64 +0 -0
- package/bin/ztc-tui-darwin-arm64 +0 -0
- package/bin/zterm-darwin-arm64 +0 -0
- package/dist/App.js +16 -2
- package/dist/agent/agent.d.ts +20 -8
- package/dist/agent/agent.js +92 -40
- package/dist/agent/backends/gemini.js +1 -1
- package/dist/agent/backends/openai_format.js +2 -2
- package/dist/agent/backends/openai_responses.js +1 -1
- package/dist/agent/backends/types.d.ts +1 -0
- package/dist/agent/commands/config.js +15 -1
- package/dist/agent/commands/settings.js +23 -0
- package/dist/agent/commands/status.js +3 -0
- package/dist/agent/commands/types.d.ts +2 -0
- package/dist/agent/completion_state.js +2 -2
- package/dist/agent/factory.js +7 -1
- package/dist/agent/request_encoder.js +3 -0
- package/dist/agent/runtime/command_jobs.d.ts +47 -0
- package/dist/agent/runtime/command_jobs.js +268 -0
- package/dist/agent/runtime/execution_context.d.ts +16 -0
- package/dist/agent/runtime/execution_context.js +20 -0
- package/dist/agent/runtime/recovery.d.ts +9 -0
- package/dist/agent/runtime/recovery.js +71 -0
- package/dist/agent/runtime/tool_runtime.js +3 -1
- package/dist/agent/runtime/tool_scheduler.d.ts +6 -0
- package/dist/agent/runtime/tool_scheduler.js +57 -0
- package/dist/agent/subagent.js +30 -12
- package/dist/agent/tool_response_store.d.ts +2 -0
- package/dist/agent/tool_response_store.js +1 -0
- package/dist/agent/tools/deferred.js +2 -0
- package/dist/agent/tools/file.js +2 -0
- package/dist/agent/tools/pane.js +139 -42
- package/dist/agent/tools/recall_tool_response.js +1 -0
- package/dist/agent/tools/registry.js +3 -1
- package/dist/agent/tools/search.js +1 -0
- package/dist/agent/tools/shell.d.ts +2 -0
- package/dist/agent/tools/shell.js +57 -2
- package/dist/agent/tools/types.d.ts +4 -0
- package/dist/app/agent_runner.js +1 -1
- package/dist/app/ordered_transcript.d.ts +13 -0
- package/dist/app/ordered_transcript.js +55 -0
- package/dist/cli_ratatui.d.ts +2 -7
- package/dist/cli_ratatui.js +53 -34
- package/dist/config/execution.d.ts +12 -0
- package/dist/config/execution.js +13 -0
- package/dist/config/types.d.ts +1 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -0
- package/dist/emulation/tool_adapters.js +5 -2
- package/dist/headless.d.ts +1 -18
- package/dist/headless.js +15 -0
- package/dist/plugin/runtime.d.ts +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/ui/ratatui/headless_pane_manager.d.ts +2 -19
- package/dist/ui/ratatui/headless_pane_manager.js +41 -34
- package/dist/ui/ratatui/pane_input.d.ts +4 -0
- package/dist/ui/ratatui/pane_input.js +15 -0
- package/dist/ui/ratatui/pane_relay.d.ts +3 -0
- package/dist/ui/ratatui/pane_relay.js +20 -2
- package/dist/ui/ratatui/protocol.d.ts +17 -0
- package/dist/ui/ratatui/protocol.js +1 -0
- package/dist/utils/shell.d.ts +2 -0
- package/dist/utils/shell.js +54 -11
- package/dist/web/mirror_stream.d.ts +2 -1
- package/dist/web/mirror_stream.js +7 -0
- package/dist/web/pane_registry.js +8 -2
- package/dist/web/view_plugin.d.ts +1 -0
- package/dist/zlm-embed.mjs +384 -118
- package/package.json +5 -5
|
@@ -126,6 +126,7 @@ export class RequestEncoder {
|
|
|
126
126
|
const verdict = this.deps.selectorVerdict;
|
|
127
127
|
const rerendered = toolResults.map(r => ({
|
|
128
128
|
tool_use_id: r.tool_use_id,
|
|
129
|
+
...(r.is_error === undefined ? {} : { is_error: r.is_error }),
|
|
129
130
|
// Coerce to '' as a last resort: a tool_result entry that carries a
|
|
130
131
|
// tool_use_id but no content must still encode as a valid tool_result
|
|
131
132
|
// block (content must be a string for the API), not as undefined —
|
|
@@ -241,6 +242,7 @@ export class RequestEncoder {
|
|
|
241
242
|
return {
|
|
242
243
|
type: 'tool_result',
|
|
243
244
|
tool_use_id: result.tool_use_id,
|
|
245
|
+
...(result.is_error === undefined ? {} : { is_error: result.is_error }),
|
|
244
246
|
content: [
|
|
245
247
|
{ type: 'text', text: parsed.text },
|
|
246
248
|
{
|
|
@@ -269,6 +271,7 @@ export class RequestEncoder {
|
|
|
269
271
|
return {
|
|
270
272
|
type: 'tool_result',
|
|
271
273
|
tool_use_id: result.tool_use_id,
|
|
274
|
+
...(result.is_error === undefined ? {} : { is_error: result.is_error }),
|
|
272
275
|
content,
|
|
273
276
|
};
|
|
274
277
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type RunShellCommandOptions } from '../../utils/shell.js';
|
|
2
|
+
export type CommandStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'timed_out' | 'interrupted';
|
|
3
|
+
export interface CommandReceipt {
|
|
4
|
+
job_id: string;
|
|
5
|
+
status: CommandStatus;
|
|
6
|
+
command: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
started_at: string;
|
|
9
|
+
ended_at?: string;
|
|
10
|
+
exit_code?: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
log_path: string;
|
|
13
|
+
owner_pid?: number;
|
|
14
|
+
output_truncated?: boolean;
|
|
15
|
+
dropped_bytes?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function subscribeCommandJobs(sessionId: string, listener: (jobs: CommandReceipt[]) => void): () => void;
|
|
18
|
+
export declare function listCommandJobs(sessionId: string): CommandReceipt[];
|
|
19
|
+
export declare function startCommandJob(options: {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
command: string;
|
|
22
|
+
cwd: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
shell: RunShellCommandOptions;
|
|
25
|
+
maxJobs: number;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}): Promise<CommandReceipt>;
|
|
28
|
+
export declare function readCommandJob(sessionId: string, jobId: string, cursor?: number, waitMs?: number, signal?: AbortSignal): Promise<{
|
|
29
|
+
output: string;
|
|
30
|
+
cursor: number;
|
|
31
|
+
has_more: boolean;
|
|
32
|
+
job_id: string;
|
|
33
|
+
status: CommandStatus;
|
|
34
|
+
command: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
started_at: string;
|
|
37
|
+
ended_at?: string;
|
|
38
|
+
exit_code?: number;
|
|
39
|
+
error?: string;
|
|
40
|
+
log_path: string;
|
|
41
|
+
owner_pid?: number;
|
|
42
|
+
output_truncated?: boolean;
|
|
43
|
+
dropped_bytes?: number;
|
|
44
|
+
}>;
|
|
45
|
+
export declare function stopCommandJob(sessionId: string, jobId: string): Promise<CommandReceipt>;
|
|
46
|
+
export declare function shutdownCommandJobs(sessionId?: string): Promise<void>;
|
|
47
|
+
export declare function interruptCommandJobs(sessionId: string): Promise<void>;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { runShellCommand } from '../../utils/shell.js';
|
|
6
|
+
const MAX_LOG_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
const MAX_RETAINED_JOBS = 64;
|
|
8
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
const listeners = new Map();
|
|
11
|
+
export function subscribeCommandJobs(sessionId, listener) {
|
|
12
|
+
let subscriptions = listeners.get(sessionId);
|
|
13
|
+
if (!subscriptions) {
|
|
14
|
+
subscriptions = new Set();
|
|
15
|
+
listeners.set(sessionId, subscriptions);
|
|
16
|
+
}
|
|
17
|
+
subscriptions.add(listener);
|
|
18
|
+
listener(listCommandJobs(sessionId));
|
|
19
|
+
return () => { subscriptions.delete(listener); if (!subscriptions.size)
|
|
20
|
+
listeners.delete(sessionId); };
|
|
21
|
+
}
|
|
22
|
+
function publish(sessionId) {
|
|
23
|
+
for (const listener of listeners.get(sessionId) ?? []) {
|
|
24
|
+
try {
|
|
25
|
+
listener(listCommandJobs(sessionId));
|
|
26
|
+
}
|
|
27
|
+
catch { /* a view cannot change command lifecycle */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Bound completed logs across all sessions; active processes keep their logs. */
|
|
31
|
+
function pruneCommandLogs() {
|
|
32
|
+
const root = join(process.env.ZTC_CONFIG_DIR || join(homedir(), '.ztc'), 'command-jobs');
|
|
33
|
+
const terminal = [];
|
|
34
|
+
try {
|
|
35
|
+
for (const folder of readdirSync(root, { withFileTypes: true })) {
|
|
36
|
+
if (!folder.isDirectory() || !/^[0-9a-f]{32}$/.test(folder.name))
|
|
37
|
+
continue;
|
|
38
|
+
const path = join(root, folder.name);
|
|
39
|
+
for (const file of readdirSync(path, { withFileTypes: true })) {
|
|
40
|
+
if (!file.isFile() || !/^[0-9a-f-]{36}\.json$/.test(file.name))
|
|
41
|
+
continue;
|
|
42
|
+
try {
|
|
43
|
+
const receipt = JSON.parse(readFileSync(join(path, file.name), 'utf8'));
|
|
44
|
+
const id = file.name.slice(0, -5);
|
|
45
|
+
if (receipt.status === 'running') {
|
|
46
|
+
if ([...sessions.values()].some(registry => registry.get(id)?.receipt.status === 'running'))
|
|
47
|
+
continue;
|
|
48
|
+
if (Number.isSafeInteger(receipt.owner_pid) && receipt.owner_pid > 0) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(receipt.owner_pid, 0);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error.code !== 'ESRCH')
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (Date.now() - Date.parse(receipt.started_at) < RETENTION_MS)
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const ended = Date.parse(receipt.ended_at ?? receipt.started_at);
|
|
62
|
+
if (Number.isFinite(ended))
|
|
63
|
+
terminal.push({ directory: path, id, ended });
|
|
64
|
+
}
|
|
65
|
+
catch { /* An incomplete receipt may belong to another writer. */ }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
terminal.sort((a, b) => b.ended - a.ended);
|
|
69
|
+
for (const [index, entry] of terminal.entries()) {
|
|
70
|
+
if (index < MAX_RETAINED_JOBS && Date.now() - entry.ended < RETENTION_MS)
|
|
71
|
+
continue;
|
|
72
|
+
// Derive both paths from validated filenames, never from receipt.log_path.
|
|
73
|
+
rmSync(join(entry.directory, `${entry.id}.log`), { force: true });
|
|
74
|
+
rmSync(join(entry.directory, `${entry.id}.json`), { force: true });
|
|
75
|
+
for (const registry of sessions.values())
|
|
76
|
+
registry.delete(entry.id);
|
|
77
|
+
try {
|
|
78
|
+
rmdirSync(entry.directory);
|
|
79
|
+
}
|
|
80
|
+
catch { /* other retained jobs remain */ }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch { /* Retention must not change a command's outcome. Retry on next job. */ }
|
|
84
|
+
}
|
|
85
|
+
function appendCommandOutput(sessionId, job, text) {
|
|
86
|
+
const bytes = Buffer.from(text);
|
|
87
|
+
let count = job.receipt.output_truncated ? 0 : Math.min(bytes.length, MAX_LOG_BYTES - job.loggedBytes);
|
|
88
|
+
// Keep a complete UTF-8 prefix; once truncated, never append later fragments.
|
|
89
|
+
if (count < bytes.length)
|
|
90
|
+
while (count > 0 && (bytes[count] & 0xc0) === 0x80)
|
|
91
|
+
count--;
|
|
92
|
+
if (count > 0) {
|
|
93
|
+
appendFileSync(job.receipt.log_path, bytes.subarray(0, count));
|
|
94
|
+
job.loggedBytes += count;
|
|
95
|
+
}
|
|
96
|
+
if (count < bytes.length) {
|
|
97
|
+
const first = !job.receipt.output_truncated;
|
|
98
|
+
job.receipt.output_truncated = true;
|
|
99
|
+
job.receipt.dropped_bytes = (job.receipt.dropped_bytes ?? 0) + bytes.length - count;
|
|
100
|
+
if (first)
|
|
101
|
+
save(sessionId, job.receipt);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function directory(sessionId) {
|
|
105
|
+
return join(process.env.ZTC_CONFIG_DIR || join(homedir(), '.ztc'), 'command-jobs', createHash('sha256').update(sessionId).digest('hex').slice(0, 32));
|
|
106
|
+
}
|
|
107
|
+
function save(sessionId, receipt) {
|
|
108
|
+
writeFileSync(join(directory(sessionId), `${receipt.job_id}.json`), JSON.stringify(receipt), { mode: 0o600 });
|
|
109
|
+
}
|
|
110
|
+
function resolveJob(sessionId, jobId) {
|
|
111
|
+
if (!/^[0-9a-f-]{36}$/.test(jobId))
|
|
112
|
+
throw new Error('Command job not found in this session');
|
|
113
|
+
const live = sessions.get(sessionId)?.get(jobId);
|
|
114
|
+
if (live)
|
|
115
|
+
return live;
|
|
116
|
+
let receipt;
|
|
117
|
+
try {
|
|
118
|
+
receipt = JSON.parse(readFileSync(join(directory(sessionId), `${jobId}.json`), 'utf8'));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new Error('Command job not found in this session');
|
|
122
|
+
}
|
|
123
|
+
if (receipt.status === 'running') {
|
|
124
|
+
receipt = { ...receipt, status: 'interrupted', ended_at: new Date().toISOString(), error: 'Owning ZTC process ended; inspect output before retrying.' };
|
|
125
|
+
save(sessionId, receipt);
|
|
126
|
+
}
|
|
127
|
+
return { receipt, controller: new AbortController(), done: Promise.resolve(), loggedBytes: statSync(receipt.log_path).size };
|
|
128
|
+
}
|
|
129
|
+
export function listCommandJobs(sessionId) {
|
|
130
|
+
const jobs = [...(sessions.get(sessionId)?.values() ?? [])];
|
|
131
|
+
return [...jobs.filter(job => job.receipt.status === 'running'), ...jobs.filter(job => job.receipt.status !== 'running').slice(-28)].map(job => ({ ...job.receipt }));
|
|
132
|
+
}
|
|
133
|
+
export async function startCommandJob(options) {
|
|
134
|
+
const { sessionId } = options;
|
|
135
|
+
let registry = sessions.get(sessionId);
|
|
136
|
+
if (!registry) {
|
|
137
|
+
registry = new Map();
|
|
138
|
+
sessions.set(sessionId, registry);
|
|
139
|
+
}
|
|
140
|
+
if ([...registry.values()].filter(job => job.receipt.status === 'running').length >= options.maxJobs) {
|
|
141
|
+
throw new Error(`Background command limit (${options.maxJobs}) reached. Read or stop an existing job first.`);
|
|
142
|
+
}
|
|
143
|
+
if (options.signal?.aborted)
|
|
144
|
+
throw new Error('Command cancelled');
|
|
145
|
+
pruneCommandLogs();
|
|
146
|
+
mkdirSync(directory(sessionId), { recursive: true, mode: 0o700 });
|
|
147
|
+
const jobId = randomUUID();
|
|
148
|
+
const logPath = join(directory(sessionId), `${jobId}.log`);
|
|
149
|
+
writeFileSync(logPath, '', { mode: 0o600 });
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const job = { controller, done: Promise.resolve(), loggedBytes: 0, receipt: {
|
|
152
|
+
job_id: jobId, status: 'running', command: options.command, cwd: options.cwd,
|
|
153
|
+
started_at: new Date().toISOString(), log_path: logPath, owner_pid: process.pid,
|
|
154
|
+
} };
|
|
155
|
+
save(sessionId, job.receipt);
|
|
156
|
+
registry.set(jobId, job);
|
|
157
|
+
publish(sessionId);
|
|
158
|
+
const abort = () => controller.abort();
|
|
159
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
160
|
+
job.done = runShellCommand(options.command, options.cwd, controller.signal, options.timeoutMs, {
|
|
161
|
+
...options.shell, noTimeout: options.timeoutMs === undefined,
|
|
162
|
+
onOutput: (_stream, text) => appendCommandOutput(sessionId, job, text),
|
|
163
|
+
}).then(result => {
|
|
164
|
+
job.receipt.exit_code = result.exitCode;
|
|
165
|
+
job.receipt.status = result.exitCode === 124 ? 'timed_out' : result.exitCode === 0 ? 'completed' : 'failed';
|
|
166
|
+
if (result.exitCode !== 0)
|
|
167
|
+
job.receipt.error = result.stderr.slice(-4096) || `Command exited with code ${result.exitCode}`;
|
|
168
|
+
}, error => {
|
|
169
|
+
if (job.receipt.status !== 'interrupted')
|
|
170
|
+
job.receipt.status = controller.signal.aborted ? 'cancelled' : 'failed';
|
|
171
|
+
job.receipt.error = error.message;
|
|
172
|
+
}).finally(() => {
|
|
173
|
+
options.signal?.removeEventListener('abort', abort);
|
|
174
|
+
job.receipt.ended_at = new Date().toISOString();
|
|
175
|
+
try {
|
|
176
|
+
save(sessionId, job.receipt);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
job.receipt.status = 'failed';
|
|
180
|
+
job.receipt.error = `Could not persist final receipt: ${error.message}`;
|
|
181
|
+
}
|
|
182
|
+
pruneCommandLogs();
|
|
183
|
+
// Terminal receipts remain on disk; bound live metadata while preserving all running jobs.
|
|
184
|
+
const terminal = [...registry.entries()].filter(([, entry]) => entry.receipt.status !== 'running');
|
|
185
|
+
for (const [id] of terminal.slice(0, Math.max(0, terminal.length - 64)))
|
|
186
|
+
registry.delete(id);
|
|
187
|
+
publish(sessionId);
|
|
188
|
+
});
|
|
189
|
+
return { ...job.receipt };
|
|
190
|
+
}
|
|
191
|
+
export async function readCommandJob(sessionId, jobId, cursor = 0, waitMs = 0, signal) {
|
|
192
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0)
|
|
193
|
+
throw new Error('cursor must be a non-negative byte offset');
|
|
194
|
+
if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 60000)
|
|
195
|
+
throw new Error('wait_ms must be an integer between 0 and 60000');
|
|
196
|
+
const job = resolveJob(sessionId, jobId);
|
|
197
|
+
const cancelled = () => new Error(`Command read cancelled; inspect job ${jobId} before retrying. The job may still be running.`);
|
|
198
|
+
if (signal?.aborted)
|
|
199
|
+
throw cancelled();
|
|
200
|
+
if (job.receipt.status === 'running' && waitMs > 0) {
|
|
201
|
+
let timer;
|
|
202
|
+
let onAbort;
|
|
203
|
+
try {
|
|
204
|
+
await Promise.race([job.done, new Promise((resolve, reject) => {
|
|
205
|
+
timer = setTimeout(resolve, waitMs);
|
|
206
|
+
onAbort = () => reject(cancelled());
|
|
207
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
208
|
+
})]);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (timer)
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
if (onAbort)
|
|
214
|
+
signal?.removeEventListener('abort', onAbort);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const size = statSync(job.receipt.log_path).size;
|
|
218
|
+
if (cursor > size)
|
|
219
|
+
throw new Error('cursor is beyond the command output');
|
|
220
|
+
const buffer = Buffer.alloc(Math.min(16384, size - cursor));
|
|
221
|
+
const fd = openSync(job.receipt.log_path, 'r');
|
|
222
|
+
let count;
|
|
223
|
+
try {
|
|
224
|
+
count = readSync(fd, buffer, 0, buffer.length, cursor);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
closeSync(fd);
|
|
228
|
+
}
|
|
229
|
+
// Cursor boundaries are byte offsets; never split an emitted UTF-8 character.
|
|
230
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
231
|
+
let output = '';
|
|
232
|
+
for (let trim = 0; trim <= Math.min(3, count); trim++) {
|
|
233
|
+
try {
|
|
234
|
+
output = decoder.decode(buffer.subarray(0, count - trim));
|
|
235
|
+
count -= trim;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
if (trim === Math.min(3, count))
|
|
240
|
+
throw new Error('cursor must be at a UTF-8 character boundary');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { ...job.receipt, output, cursor: cursor + count, has_more: cursor + count < size };
|
|
244
|
+
}
|
|
245
|
+
export async function stopCommandJob(sessionId, jobId) {
|
|
246
|
+
const job = resolveJob(sessionId, jobId);
|
|
247
|
+
if (job.receipt.status === 'running') {
|
|
248
|
+
job.controller.abort();
|
|
249
|
+
await job.done;
|
|
250
|
+
}
|
|
251
|
+
return { ...job.receipt };
|
|
252
|
+
}
|
|
253
|
+
export async function shutdownCommandJobs(sessionId) {
|
|
254
|
+
const registries = sessionId ? [sessions.get(sessionId)] : [...sessions.values()];
|
|
255
|
+
const pending = [];
|
|
256
|
+
for (const registry of registries)
|
|
257
|
+
for (const job of registry?.values() ?? []) {
|
|
258
|
+
if (job.receipt.status !== 'running')
|
|
259
|
+
continue;
|
|
260
|
+
job.receipt.status = 'interrupted';
|
|
261
|
+
job.controller.abort();
|
|
262
|
+
pending.push(job.done);
|
|
263
|
+
}
|
|
264
|
+
await Promise.all(pending);
|
|
265
|
+
}
|
|
266
|
+
export async function interruptCommandJobs(sessionId) {
|
|
267
|
+
await Promise.all(listCommandJobs(sessionId).filter(job => job.status === 'running').map(job => stopCommandJob(sessionId, job.job_id)));
|
|
268
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExecutionSettings } from '../../config/execution.js';
|
|
2
|
+
import { resolveExecutionSettings } from '../../config/execution.js';
|
|
3
|
+
import type { CommandReceipt } from './command_jobs.js';
|
|
4
|
+
export interface ExecutionContext {
|
|
5
|
+
mode: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
tools: string[];
|
|
8
|
+
mux: boolean;
|
|
9
|
+
configScope: string;
|
|
10
|
+
maxTokens?: number;
|
|
11
|
+
outputLimitSource: string;
|
|
12
|
+
settings: Required<ExecutionSettings>;
|
|
13
|
+
jobs: CommandReceipt[];
|
|
14
|
+
}
|
|
15
|
+
export declare function describeExecutionContext(context: ExecutionContext): string;
|
|
16
|
+
export { resolveExecutionSettings };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolveExecutionSettings } from '../../config/execution.js';
|
|
2
|
+
export function describeExecutionContext(context) {
|
|
3
|
+
const names = new Set(context.tools);
|
|
4
|
+
const shell = ['run_command', 'Bash', 'exec_command', 'shell_command'].filter(name => names.has(name));
|
|
5
|
+
const panes = ['create_terminal_pane', 'read_terminal_pane', 'write_terminal_pane'].filter(name => names.has(name));
|
|
6
|
+
return [
|
|
7
|
+
'## Current execution capabilities (host facts; refreshed each request)',
|
|
8
|
+
`Mode: ${context.mode}. Cwd: ${context.cwd}. Config write scope: ${context.configScope}.`,
|
|
9
|
+
`Output cap: ${context.maxTokens ?? 'provider default'} (source: ${context.outputLimitSource}). Use /config max-tokens <number|default> to change it.`,
|
|
10
|
+
`Shell: ${shell.length ? shell.join(', ') : 'unavailable in the current tool surface'}.`,
|
|
11
|
+
`Mux: ${context.mux ? 'connected' : 'unavailable'}. Pane tools: ${panes.join(', ') || 'unavailable in this mode'}.`,
|
|
12
|
+
`Shell-pane policy: ${context.settings.shellPanePolicy === 'on_request' ? 'create a shell pane only when the user explicitly requests a visible or interactive shell; otherwise use run_command' : 'may create shell panes automatically when useful'}.`,
|
|
13
|
+
`Registered tools: ${context.tools.slice(0, 80).join(', ')}. Deferred schemas can be discovered with tool_search when available.`,
|
|
14
|
+
`Batch limit: ${context.settings.maxCallsPerResponse}. Read concurrency: ${context.settings.readConcurrency}. Inspect a failed or running command before submitting dependent work.`,
|
|
15
|
+
'For multiline scripts use run_command stdin or write_file and execute the file. Pane text supports multiline submission; raw Ctrl+C (U+0003, submit=false) cancels stuck shell input. A write acknowledgement does not prove command success.',
|
|
16
|
+
`Managed commands: ${context.jobs.length ? JSON.stringify(context.jobs.map(({ job_id, status, cwd, exit_code }) => ({ job_id, status, cwd, exit_code }))) : 'none'}. Background commands ${context.settings.interruptBackgroundJobs ? 'are cancelled' : 'survive'} on turn interruption; application shutdown stops owned local jobs. Remote cleanup belongs to the launched command.`,
|
|
17
|
+
'If a tool is unavailable, inspect these current facts and mode restrictions. Do not infer that the session never had shell access from a previous mode or a failed invocation.',
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
export { resolveExecutionSettings };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ExecutionSettings } from '../../config/execution.js';
|
|
2
|
+
/** Group failures by cause rather than command spelling, paths, or changing IDs. */
|
|
3
|
+
export declare function normalizeFailure(content: string): string;
|
|
4
|
+
/** One budget per run; a new, substantive successful observation resets it. */
|
|
5
|
+
export declare function createRecoveryGuard(settings: ExecutionSettings): (results: readonly {
|
|
6
|
+
name: string;
|
|
7
|
+
content: string;
|
|
8
|
+
isError: boolean;
|
|
9
|
+
}[]) => "continue" | "recover" | "stop";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolveExecutionSettings } from '../../config/execution.js';
|
|
2
|
+
/** Group failures by cause rather than command spelling, paths, or changing IDs. */
|
|
3
|
+
export function normalizeFailure(content) {
|
|
4
|
+
const text = content.toLowerCase();
|
|
5
|
+
if (/not_executed/.test(text))
|
|
6
|
+
return '';
|
|
7
|
+
for (const [pattern, cause] of [
|
|
8
|
+
[/heredoc|unexpected end of file|unterminated/, 'unfinished shell input'],
|
|
9
|
+
[/permission denied|eacces/, 'permission denied'],
|
|
10
|
+
[/not found|enoent|unknown tool|unavailable/, 'capability or file unavailable'],
|
|
11
|
+
[/timeout|timed out/, 'timeout'],
|
|
12
|
+
[/syntaxerror|syntax error|invalid.*syntax/, 'syntax error'],
|
|
13
|
+
])
|
|
14
|
+
if (pattern.test(text))
|
|
15
|
+
return cause;
|
|
16
|
+
const exit = text.match(/command failed \(exit (\d+)\)/);
|
|
17
|
+
if (exit)
|
|
18
|
+
return `command exit ${exit[1]}`;
|
|
19
|
+
return text.split('\n')[0].replace(/['"`][^'"`]*['"`]/g, '<value>').replace(/\d+/g, '#').trim().slice(0, 240);
|
|
20
|
+
}
|
|
21
|
+
/** One budget per run; a new, substantive successful observation resets it. */
|
|
22
|
+
export function createRecoveryGuard(settings) {
|
|
23
|
+
const limits = resolveExecutionSettings(settings);
|
|
24
|
+
let cause = '';
|
|
25
|
+
let failures = 0;
|
|
26
|
+
let attempts = 0;
|
|
27
|
+
let neutralSteps = 0;
|
|
28
|
+
const observations = new Set();
|
|
29
|
+
return (results) => {
|
|
30
|
+
let failed = false;
|
|
31
|
+
let progress = false;
|
|
32
|
+
let neutral = false;
|
|
33
|
+
for (const result of results) {
|
|
34
|
+
if (result.isError) {
|
|
35
|
+
const next = normalizeFailure(result.content);
|
|
36
|
+
if (!next)
|
|
37
|
+
continue;
|
|
38
|
+
failed = true;
|
|
39
|
+
failures = next === cause ? failures + 1 : 1;
|
|
40
|
+
cause = next;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const signature = `${result.name}\0${result.content}`;
|
|
44
|
+
// Acknowledgements and unchanged polling are delivery facts, not progress.
|
|
45
|
+
const acknowledgement = /^(Input (?:delivered|queued|sent)|Sent input|\(Terminal buffer is empty\))/.test(result.content)
|
|
46
|
+
|| /"status":"running"/.test(result.content) && /"output":""/.test(result.content);
|
|
47
|
+
neutral ||= acknowledgement;
|
|
48
|
+
if (result.content.trim() && !acknowledgement && !observations.has(signature))
|
|
49
|
+
progress = true;
|
|
50
|
+
observations.add(signature);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (progress && !failed) {
|
|
54
|
+
failures = 0;
|
|
55
|
+
cause = '';
|
|
56
|
+
attempts = 0;
|
|
57
|
+
neutralSteps = 0;
|
|
58
|
+
}
|
|
59
|
+
if (failures < limits.repeatedFailureLimit)
|
|
60
|
+
return 'continue';
|
|
61
|
+
// One neutral response may deliver an interrupt or wait for a job, then
|
|
62
|
+
// the model must inspect new output. This never renews the recovery budget.
|
|
63
|
+
if (!failed && neutral && attempts > 0 && neutralSteps++ < 1)
|
|
64
|
+
return 'continue';
|
|
65
|
+
if (limits.recovery === 'pause' || attempts >= limits.recoveryAttempts)
|
|
66
|
+
return 'stop';
|
|
67
|
+
attempts++;
|
|
68
|
+
neutralSteps = 0;
|
|
69
|
+
return 'recover';
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -27,7 +27,7 @@ function isHostControlPlaneWrite(tool, args, cwd) {
|
|
|
27
27
|
async function executeTool(name, args, tools, context) {
|
|
28
28
|
const tool = getTool(name, tools);
|
|
29
29
|
if (!tool)
|
|
30
|
-
throw new Error(
|
|
30
|
+
throw new Error(`Unknown tool: ${name}. It is unavailable in the current mode or provider surface. Inspect current execution capabilities and use an available tool; shell execution requires run_command or a registered alias.`);
|
|
31
31
|
return tool.execute(args, context);
|
|
32
32
|
}
|
|
33
33
|
function buildHookText(messages) {
|
|
@@ -319,6 +319,8 @@ export async function executeToolWithRuntime(name, args, options) {
|
|
|
319
319
|
]).finally(() => { if (timer)
|
|
320
320
|
clearTimeout(timer); });
|
|
321
321
|
}
|
|
322
|
+
if (tool?.isErrorResult?.(output))
|
|
323
|
+
throw new Error(output);
|
|
322
324
|
// Global output cap: prevent any single tool result from dominating the
|
|
323
325
|
// context window. Claude Code caps at ~100K chars; we use 80K (~20K tokens).
|
|
324
326
|
// The model gets the head + tail of large outputs to preserve both the
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExecutionSettings } from '../../config/execution.js';
|
|
2
|
+
import type { ToolUseBlock } from '../backends/types.js';
|
|
3
|
+
import type { Tool } from '../tools/types.js';
|
|
4
|
+
/** Only explicitly declared reads may overlap. Writes and unknown tools are barriers. */
|
|
5
|
+
export declare function scheduleToolBatch<T>(calls: readonly ToolUseBlock[], tools: readonly Tool[], settings: ExecutionSettings, execute: (call: ToolUseBlock, skipReason?: string) => Promise<T>, blocksFollowing: (result: T) => boolean, signal?: AbortSignal, exclusiveId?: string | null): Promise<T[]>;
|
|
6
|
+
export declare function commandStillRunning(content: string): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { resolveExecutionSettings } from '../../config/execution.js';
|
|
2
|
+
import { resolveToolEffect } from './tool_effects.js';
|
|
3
|
+
/** Only explicitly declared reads may overlap. Writes and unknown tools are barriers. */
|
|
4
|
+
export async function scheduleToolBatch(calls, tools, settings, execute, blocksFollowing, signal, exclusiveId) {
|
|
5
|
+
const limits = resolveExecutionSettings(settings);
|
|
6
|
+
const registered = new Map(tools.map(tool => [tool.definition.name, tool]));
|
|
7
|
+
const parallel = (call) => {
|
|
8
|
+
const tool = registered.get(call.name);
|
|
9
|
+
return tool?.parallelSafe === true && resolveToolEffect(tool) === 'read';
|
|
10
|
+
};
|
|
11
|
+
const results = [];
|
|
12
|
+
let effectsBlocked = false;
|
|
13
|
+
let stopped = calls.length > limits.maxCallsPerResponse
|
|
14
|
+
? `batch_limit: ${calls.length} calls exceeds ${limits.maxCallsPerResponse}; submit a smaller batch after inspecting results.`
|
|
15
|
+
: '';
|
|
16
|
+
for (let index = 0; index < calls.length;) {
|
|
17
|
+
const call = calls[index];
|
|
18
|
+
if (signal?.aborted)
|
|
19
|
+
stopped = 'cancelled: the turn was interrupted.';
|
|
20
|
+
if (stopped) {
|
|
21
|
+
results.push(await execute(call, `not_executed: ${stopped}`));
|
|
22
|
+
index++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (effectsBlocked && !parallel(call)) {
|
|
26
|
+
results.push(await execute(call, 'not_executed: a previous call failed or is still running; inspect its result before continuing effects.'));
|
|
27
|
+
index++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (exclusiveId && call.id !== exclusiveId) {
|
|
31
|
+
results.push(await execute(call, 'not_executed: exclusive safety transition must run alone.'));
|
|
32
|
+
index++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let end = index + 1;
|
|
36
|
+
if (!exclusiveId && parallel(call)) {
|
|
37
|
+
while (end < calls.length && end - index < limits.readConcurrency && parallel(calls[end]))
|
|
38
|
+
end++;
|
|
39
|
+
}
|
|
40
|
+
const batch = await Promise.all(calls.slice(index, end).map(item => execute(item)));
|
|
41
|
+
results.push(...batch);
|
|
42
|
+
if (batch.some(blocksFollowing)) {
|
|
43
|
+
effectsBlocked = true;
|
|
44
|
+
}
|
|
45
|
+
index = end;
|
|
46
|
+
}
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
export function commandStillRunning(content) {
|
|
50
|
+
try {
|
|
51
|
+
const result = JSON.parse(content);
|
|
52
|
+
return result.status === 'running' && typeof result.job_id === 'string';
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/agent/subagent.js
CHANGED
|
@@ -14,6 +14,9 @@ import { debugLog } from '../utils/debug_log.js';
|
|
|
14
14
|
import { createBackendForProvider } from './backends/index.js';
|
|
15
15
|
import { accountBackendOptions, resolveRotation } from './backends/account_pool.js';
|
|
16
16
|
import { buildAccountRotation, subagentShouldRotate } from './backends/rotation_factory.js';
|
|
17
|
+
import { describeExecutionContext, resolveExecutionSettings } from './runtime/execution_context.js';
|
|
18
|
+
import { createRecoveryGuard } from './runtime/recovery.js';
|
|
19
|
+
import { scheduleToolBatch, commandStillRunning } from './runtime/tool_scheduler.js';
|
|
17
20
|
import { configStore } from '../config.js';
|
|
18
21
|
import { ToolResponseStore } from './tool_response_store.js';
|
|
19
22
|
import { RESEARCH_TOOLS } from '../worker/toolsets.js';
|
|
@@ -118,6 +121,7 @@ export class SubAgent {
|
|
|
118
121
|
let completedCleanly = false;
|
|
119
122
|
// R5 parity: same progress-delta guard as the main loop (shared loop_policy).
|
|
120
123
|
const applyRepeatGuard = createRepeatGuard();
|
|
124
|
+
const recovery = createRecoveryGuard(configStore.get().execution ?? {});
|
|
121
125
|
debug('Starting task: %s', task.slice(0, 100) + (task.length > 100 ? '...' : ''));
|
|
122
126
|
const systemPrompt = opts?.systemPrompt ?? renderPrompt('subagent_research', {
|
|
123
127
|
max_iterations: maxIterations,
|
|
@@ -153,7 +157,11 @@ export class SubAgent {
|
|
|
153
157
|
maxTokens: 2048,
|
|
154
158
|
// R7 parity: pin the task in request.system so a long subagent run
|
|
155
159
|
// can't lose the task framing the same way the main loop could (F9).
|
|
156
|
-
system: pinnedTaskSystem(systemPrompt, task)
|
|
160
|
+
system: pinnedTaskSystem(systemPrompt, task) + '\n\n' + describeExecutionContext({
|
|
161
|
+
mode: 'delegated worker', cwd: this.cwd, tools: tools.map(tool => tool.definition.name),
|
|
162
|
+
mux: false, configScope: configStore.getConfigScope(), maxTokens: 2048,
|
|
163
|
+
outputLimitSource: 'delegated worker budget', settings: resolveExecutionSettings(configStore.get().execution), jobs: [],
|
|
164
|
+
}),
|
|
157
165
|
messages: this.formatMessages(messages),
|
|
158
166
|
tools: tools.map(t => t.definition),
|
|
159
167
|
// The inherited backend is pinned to the parent conversation; this is
|
|
@@ -194,12 +202,11 @@ export class SubAgent {
|
|
|
194
202
|
}
|
|
195
203
|
debug('Executing %d tool(s): %s', toolUseBlocks.length, toolUseBlocks.map(tb => tb.name).join(', '));
|
|
196
204
|
// Execute tools and track what was accessed
|
|
197
|
-
const toolResults =
|
|
198
|
-
for (const toolBlock of toolUseBlocks) {
|
|
199
|
-
if (signal?.aborted)
|
|
200
|
-
throw new Error('Sub-agent cancelled');
|
|
201
|
-
checkTimeout();
|
|
205
|
+
const toolResults = await scheduleToolBatch(toolUseBlocks, tools, configStore.get().execution ?? {}, async (toolBlock, skipReason) => {
|
|
202
206
|
try {
|
|
207
|
+
if (skipReason)
|
|
208
|
+
throw new Error(skipReason);
|
|
209
|
+
checkTimeout();
|
|
203
210
|
const toolStart = Date.now();
|
|
204
211
|
const result = await executeToolWithRuntime(toolBlock.name, toolBlock.input, {
|
|
205
212
|
tools,
|
|
@@ -222,19 +229,21 @@ export class SubAgent {
|
|
|
222
229
|
if (!result.isError) {
|
|
223
230
|
this.trackToolUsage(toolBlock.name, result.effectiveArgs, result.content);
|
|
224
231
|
}
|
|
225
|
-
|
|
232
|
+
return {
|
|
233
|
+
is_error: result.isError,
|
|
226
234
|
tool_use_id: toolBlock.id,
|
|
227
235
|
content: applyRepeatGuard(toolBlock.name, toolBlock.input, result.isError ? `Error: ${result.content}` : result.content),
|
|
228
|
-
}
|
|
236
|
+
};
|
|
229
237
|
}
|
|
230
238
|
catch (err) {
|
|
231
239
|
debug('Tool %s failed: %s', toolBlock.name, err.message);
|
|
232
|
-
|
|
240
|
+
return {
|
|
241
|
+
is_error: true,
|
|
233
242
|
tool_use_id: toolBlock.id,
|
|
234
243
|
content: `Error: ${err.message}`
|
|
235
|
-
}
|
|
244
|
+
};
|
|
236
245
|
}
|
|
237
|
-
}
|
|
246
|
+
}, result => result.is_error || commandStillRunning(result.content), signal);
|
|
238
247
|
opts?.onIteration?.({
|
|
239
248
|
iteration: iterations,
|
|
240
249
|
max: maxIterations,
|
|
@@ -268,6 +277,14 @@ export class SubAgent {
|
|
|
268
277
|
content: JSON.stringify(toolResults),
|
|
269
278
|
timestamp: new Date()
|
|
270
279
|
});
|
|
280
|
+
const recoveryAction = recovery(toolResults.map((result, index) => ({
|
|
281
|
+
name: toolUseBlocks[index].name, content: result.content, isError: result.is_error,
|
|
282
|
+
})));
|
|
283
|
+
if (recoveryAction === 'stop')
|
|
284
|
+
throw new Error('Automatic recovery budget exhausted after repeated tool failures.');
|
|
285
|
+
if (recoveryAction === 'recover')
|
|
286
|
+
messages.push({ id: `recovery_${iterations}`, role: 'user',
|
|
287
|
+
content: 'Repeated failures: inspect current capabilities, then try one changed recovery approach. Do not repeat the failing sequence.', timestamp: new Date() });
|
|
271
288
|
}
|
|
272
289
|
const durationMs = Date.now() - startTime;
|
|
273
290
|
// Exhausted maxIterations while still requesting tools = did NOT finish.
|
|
@@ -375,7 +392,8 @@ export class SubAgent {
|
|
|
375
392
|
content: toolResults.map(tr => ({
|
|
376
393
|
type: 'tool_result',
|
|
377
394
|
tool_use_id: tr.tool_use_id,
|
|
378
|
-
content: tr.content
|
|
395
|
+
content: tr.content,
|
|
396
|
+
is_error: tr.is_error,
|
|
379
397
|
}))
|
|
380
398
|
};
|
|
381
399
|
}
|
|
@@ -24,6 +24,7 @@ export interface ToolResponseMeta {
|
|
|
24
24
|
timestamp: string;
|
|
25
25
|
turn: number;
|
|
26
26
|
partial?: boolean;
|
|
27
|
+
is_error?: boolean;
|
|
27
28
|
source_path?: string;
|
|
28
29
|
content_hash?: string;
|
|
29
30
|
recalledAtTurn?: number;
|
|
@@ -98,6 +99,7 @@ export declare class ToolResponseStore {
|
|
|
98
99
|
put(toolUseId: string, toolName: string, content: string, partial?: boolean, context?: {
|
|
99
100
|
sourcePath?: string | null;
|
|
100
101
|
dedupable?: boolean;
|
|
102
|
+
isError?: boolean;
|
|
101
103
|
}): {
|
|
102
104
|
meta: ToolResponseMeta;
|
|
103
105
|
rendered: string;
|