@animalabs/agent-framework 0.7.0 → 0.7.2
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/dist/src/code-execution/index.d.ts +14 -0
- package/dist/src/code-execution/index.d.ts.map +1 -0
- package/dist/src/code-execution/index.js +13 -0
- package/dist/src/code-execution/index.js.map +1 -0
- package/dist/src/code-execution/py-runner.d.ts +91 -0
- package/dist/src/code-execution/py-runner.d.ts.map +1 -0
- package/dist/src/code-execution/py-runner.js +364 -0
- package/dist/src/code-execution/py-runner.js.map +1 -0
- package/dist/src/code-execution/runtime-py.d.ts +32 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -0
- package/dist/src/code-execution/runtime-py.js +265 -0
- package/dist/src/code-execution/runtime-py.js.map +1 -0
- package/dist/src/code-execution/tool-definition.d.ts +16 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -0
- package/dist/src/code-execution/tool-definition.js +45 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -0
- package/dist/src/framework.d.ts +43 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +274 -26
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +12 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +45 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +24 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -0
- package/dist/src/mcpl/prose-grammar.js +33 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -0
- package/dist/src/mcpl/prose-stream-router.d.ts +58 -0
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -0
- package/dist/src/mcpl/prose-stream-router.js +168 -0
- package/dist/src/mcpl/prose-stream-router.js.map +1 -0
- package/dist/src/types/framework.d.ts +33 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/code-execution.test.d.ts +11 -0
- package/dist/test/code-execution.test.d.ts.map +1 -0
- package/dist/test/code-execution.test.js +412 -0
- package/dist/test/code-execution.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.d.ts +2 -0
- package/dist/test/prose-stream-router.test.d.ts.map +1 -0
- package/dist/test/prose-stream-router.test.js +87 -0
- package/dist/test/prose-stream-router.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/code-execution/index.ts +19 -0
- package/src/code-execution/py-runner.ts +433 -0
- package/src/code-execution/runtime-py.ts +265 -0
- package/src/code-execution/tool-definition.ts +52 -0
- package/src/framework.ts +307 -28
- package/src/index.ts +17 -0
- package/src/mcpl/channel-registry.ts +53 -0
- package/src/mcpl/prose-grammar.ts +40 -0
- package/src/mcpl/prose-stream-router.ts +161 -0
- package/src/types/framework.ts +35 -0
package/package.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side programmatic tool calling (PTC) for the connectome native stack.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the surface Anthropic's server-side PTC was trained on
|
|
5
|
+
* (code_execution tool + tools exposed as async python functions), but runs
|
|
6
|
+
* the interpreter on the host and dispatches inner tool calls in-process
|
|
7
|
+
* through the framework's own tool routing — intermediate results never
|
|
8
|
+
* enter the model's context; the full trace stays on-box.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { PyRunner, buildInjectedTools } from './py-runner.js';
|
|
12
|
+
export type {
|
|
13
|
+
InjectedTool,
|
|
14
|
+
ExecResult,
|
|
15
|
+
PyRunnerOptions,
|
|
16
|
+
ScriptToolCallHandler,
|
|
17
|
+
} from './py-runner.js';
|
|
18
|
+
export { PYTHON_RUNTIME_SOURCE } from './runtime-py.js';
|
|
19
|
+
export { buildCodeExecutionToolDefinition, CODE_EXECUTION_TOOL_NAME } from './tool-definition.js';
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PyRunner — subprocess lifecycle + protocol driver for the client-side
|
|
3
|
+
* programmatic tool calling (`code_execution`) runtime.
|
|
4
|
+
*
|
|
5
|
+
* One runner per agent. The interpreter is spawned lazily on first exec,
|
|
6
|
+
* persists between execs (script globals survive — container-reuse
|
|
7
|
+
* semantics), and is reclaimed after an idle period, mirroring the managed
|
|
8
|
+
* runtime's ~5-minute container reclaim.
|
|
9
|
+
*
|
|
10
|
+
* This is a ROBUSTNESS boundary, not a security sandbox — same doctrine as
|
|
11
|
+
* GateScript: the agent already has broader host access through its tools.
|
|
12
|
+
* What this class guarantees is liveness: a wedged or runaway script cannot
|
|
13
|
+
* hang the agent turn (cancel -> grace -> SIGKILL -> respawn) and a crashed
|
|
14
|
+
* interpreter surfaces as a tool result, never as an unhandled rejection.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { createInterface, type Interface } from 'node:readline';
|
|
22
|
+
import { PYTHON_RUNTIME_SOURCE } from './runtime-py.js';
|
|
23
|
+
|
|
24
|
+
export interface InjectedTool {
|
|
25
|
+
/** Python identifier the tool is bound to (sanitized, `--` -> `__`). */
|
|
26
|
+
pyName: string;
|
|
27
|
+
/** Exact framework tool name, sent back on tool_call ops. */
|
|
28
|
+
toolName: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ExecResult {
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
returnCode: number;
|
|
35
|
+
/** Set when the host killed the script (deadline / abort / crash). */
|
|
36
|
+
aborted?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolves inner tool calls. Must never reject — map errors to strings. */
|
|
40
|
+
export type ScriptToolCallHandler = (
|
|
41
|
+
toolName: string,
|
|
42
|
+
args: Record<string, unknown>,
|
|
43
|
+
) => Promise<string>;
|
|
44
|
+
|
|
45
|
+
export interface PyRunnerOptions {
|
|
46
|
+
pythonPath?: string;
|
|
47
|
+
/** Per-tool-call timeout surfaced as TimeoutError inside the script. */
|
|
48
|
+
toolCallTimeoutMs?: number;
|
|
49
|
+
/** Whole-script deadline; exceeded -> cancel, grace, kill. */
|
|
50
|
+
scriptTimeoutMs?: number;
|
|
51
|
+
/** Idle interpreter reclaim (state lost), mirroring container reclaim. */
|
|
52
|
+
idleReclaimMs?: number;
|
|
53
|
+
onToolCall: ScriptToolCallHandler;
|
|
54
|
+
/** Log prefix, typically the agent name. */
|
|
55
|
+
label?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_TOOL_CALL_TIMEOUT_MS = 270_000;
|
|
59
|
+
const DEFAULT_SCRIPT_TIMEOUT_MS = 600_000;
|
|
60
|
+
const DEFAULT_IDLE_RECLAIM_MS = 300_000;
|
|
61
|
+
const CANCEL_GRACE_MS = 10_000;
|
|
62
|
+
|
|
63
|
+
interface PendingExec {
|
|
64
|
+
id: string;
|
|
65
|
+
resolve: (result: ExecResult) => void;
|
|
66
|
+
deadlineTimer: ReturnType<typeof setTimeout> | null;
|
|
67
|
+
killTimer: ReturnType<typeof setTimeout> | null;
|
|
68
|
+
settled: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class PyRunner {
|
|
72
|
+
private readonly pythonPath: string;
|
|
73
|
+
private readonly toolCallTimeoutMs: number;
|
|
74
|
+
private readonly scriptTimeoutMs: number;
|
|
75
|
+
private readonly idleReclaimMs: number;
|
|
76
|
+
private readonly onToolCall: ScriptToolCallHandler;
|
|
77
|
+
private readonly label: string;
|
|
78
|
+
|
|
79
|
+
private child: ChildProcessWithoutNullStreams | null = null;
|
|
80
|
+
private childReady: Promise<void> | null = null;
|
|
81
|
+
private reader: Interface | null = null;
|
|
82
|
+
private runtimeDir: string | null = null;
|
|
83
|
+
private pending: PendingExec | null = null;
|
|
84
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
private execCounter = 0;
|
|
86
|
+
private disposed = false;
|
|
87
|
+
|
|
88
|
+
constructor(options: PyRunnerOptions) {
|
|
89
|
+
this.pythonPath = options.pythonPath ?? 'python3';
|
|
90
|
+
this.toolCallTimeoutMs = options.toolCallTimeoutMs ?? DEFAULT_TOOL_CALL_TIMEOUT_MS;
|
|
91
|
+
this.scriptTimeoutMs = options.scriptTimeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS;
|
|
92
|
+
this.idleReclaimMs = options.idleReclaimMs ?? DEFAULT_IDLE_RECLAIM_MS;
|
|
93
|
+
this.onToolCall = options.onToolCall;
|
|
94
|
+
this.label = options.label ?? 'pytc';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get busy(): boolean {
|
|
98
|
+
return this.pending !== null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run one script. Tools are (re-)injected before every exec so the
|
|
103
|
+
* interpreter always reflects the current tool surface (list_changed etc.).
|
|
104
|
+
* Never rejects: every failure mode resolves to an ExecResult.
|
|
105
|
+
*/
|
|
106
|
+
async exec(code: string, tools: InjectedTool[]): Promise<ExecResult> {
|
|
107
|
+
if (this.disposed) {
|
|
108
|
+
return { stdout: '', stderr: 'code_execution runner disposed', returnCode: 1, aborted: true };
|
|
109
|
+
}
|
|
110
|
+
if (this.pending) {
|
|
111
|
+
return {
|
|
112
|
+
stdout: '',
|
|
113
|
+
stderr: 'RuntimeError: another code_execution script is already running for this agent',
|
|
114
|
+
returnCode: 1,
|
|
115
|
+
aborted: true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
this.clearIdleTimer();
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await this.ensureChild();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
this.reclaim('spawn-failed');
|
|
125
|
+
return {
|
|
126
|
+
stdout: '',
|
|
127
|
+
stderr: `Failed to start python runtime (${this.pythonPath}): ${message}`,
|
|
128
|
+
returnCode: 1,
|
|
129
|
+
aborted: true,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const execId = `e${++this.execCounter}`;
|
|
134
|
+
const result = await new Promise<ExecResult>((resolve) => {
|
|
135
|
+
const pending: PendingExec = {
|
|
136
|
+
id: execId,
|
|
137
|
+
resolve,
|
|
138
|
+
deadlineTimer: null,
|
|
139
|
+
killTimer: null,
|
|
140
|
+
settled: false,
|
|
141
|
+
};
|
|
142
|
+
this.pending = pending;
|
|
143
|
+
|
|
144
|
+
pending.deadlineTimer = setTimeout(() => {
|
|
145
|
+
// Deadline: ask politely first (script sees CancelledError and its
|
|
146
|
+
// exec_result still flows back), then kill on unresponsiveness.
|
|
147
|
+
this.send({ op: 'cancel', id: execId, reason: 'deadline' });
|
|
148
|
+
pending.killTimer = setTimeout(() => {
|
|
149
|
+
this.settlePending({
|
|
150
|
+
stdout: '',
|
|
151
|
+
stderr: `script killed after exceeding ${Math.round(this.scriptTimeoutMs / 1000)}s deadline`,
|
|
152
|
+
returnCode: 1,
|
|
153
|
+
aborted: true,
|
|
154
|
+
});
|
|
155
|
+
this.reclaim('deadline-kill');
|
|
156
|
+
}, CANCEL_GRACE_MS);
|
|
157
|
+
}, this.scriptTimeoutMs);
|
|
158
|
+
|
|
159
|
+
this.send({
|
|
160
|
+
op: 'init',
|
|
161
|
+
tools: tools.map((t) => ({ py_name: t.pyName, tool_name: t.toolName })),
|
|
162
|
+
call_timeout_s: Math.round(this.toolCallTimeoutMs / 1000),
|
|
163
|
+
});
|
|
164
|
+
this.send({ op: 'exec', id: execId, code });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this.armIdleTimer();
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Abort a running script (turn cancelled, agent reset/stopped). The
|
|
173
|
+
* interpreter is reclaimed: after an abort its state is suspect (a script
|
|
174
|
+
* died midway), and the next exec gets a fresh one.
|
|
175
|
+
*/
|
|
176
|
+
abort(reason: string): void {
|
|
177
|
+
if (!this.pending) return;
|
|
178
|
+
this.send({ op: 'cancel', id: this.pending.id, reason });
|
|
179
|
+
this.settlePending({
|
|
180
|
+
stdout: '',
|
|
181
|
+
stderr: `script aborted by host: ${reason}`,
|
|
182
|
+
returnCode: 1,
|
|
183
|
+
aborted: true,
|
|
184
|
+
});
|
|
185
|
+
this.reclaim(`abort: ${reason}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
dispose(): void {
|
|
189
|
+
if (this.disposed) return;
|
|
190
|
+
this.disposed = true;
|
|
191
|
+
if (this.pending) {
|
|
192
|
+
this.settlePending({
|
|
193
|
+
stdout: '',
|
|
194
|
+
stderr: 'code_execution runner disposed',
|
|
195
|
+
returnCode: 1,
|
|
196
|
+
aborted: true,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
this.reclaim('dispose');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// -------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
private async ensureChild(): Promise<void> {
|
|
205
|
+
if (this.child && this.child.exitCode === null && !this.child.killed) {
|
|
206
|
+
return this.childReady ?? Promise.resolve();
|
|
207
|
+
}
|
|
208
|
+
this.teardownChild();
|
|
209
|
+
|
|
210
|
+
this.runtimeDir = mkdtempSync(join(tmpdir(), 'af-pytc-'));
|
|
211
|
+
const runtimePath = join(this.runtimeDir, 'runtime.py');
|
|
212
|
+
writeFileSync(runtimePath, PYTHON_RUNTIME_SOURCE, 'utf8');
|
|
213
|
+
|
|
214
|
+
const child = spawn(this.pythonPath, [runtimePath], {
|
|
215
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
216
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
217
|
+
});
|
|
218
|
+
this.child = child;
|
|
219
|
+
|
|
220
|
+
child.stderr.setEncoding('utf8');
|
|
221
|
+
child.stderr.on('data', (chunk: string) => {
|
|
222
|
+
// Outside an exec, python stderr is runtime diagnostics (crash
|
|
223
|
+
// tracebacks etc.) — surface them in host logs.
|
|
224
|
+
console.error(`[pytc:${this.label}] ${chunk.trimEnd()}`);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
child.on('error', (err) => {
|
|
228
|
+
// Spawn failure lands here (e.g. python3 missing); readiness rejects.
|
|
229
|
+
console.error(`[pytc:${this.label}] python process error: ${err.message}`);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
child.on('exit', (exitCode, signal) => {
|
|
233
|
+
if (this.pending) {
|
|
234
|
+
this.settlePending({
|
|
235
|
+
stdout: '',
|
|
236
|
+
stderr: `python runtime exited unexpectedly (code=${exitCode}, signal=${signal ?? 'none'})`,
|
|
237
|
+
returnCode: 1,
|
|
238
|
+
aborted: true,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (this.child === child) {
|
|
242
|
+
this.teardownChild();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
this.reader = createInterface({ input: child.stdout });
|
|
247
|
+
this.reader.on('line', (line) => this.handleLine(line));
|
|
248
|
+
|
|
249
|
+
this.childReady = new Promise<void>((resolve, reject) => {
|
|
250
|
+
const onReady = () => {
|
|
251
|
+
cleanup();
|
|
252
|
+
resolve();
|
|
253
|
+
};
|
|
254
|
+
const onExit = () => {
|
|
255
|
+
cleanup();
|
|
256
|
+
reject(new Error('python runtime exited before becoming ready'));
|
|
257
|
+
};
|
|
258
|
+
const onError = (err: Error) => {
|
|
259
|
+
cleanup();
|
|
260
|
+
reject(err);
|
|
261
|
+
};
|
|
262
|
+
const timeout = setTimeout(() => {
|
|
263
|
+
cleanup();
|
|
264
|
+
reject(new Error('python runtime did not become ready within 15s'));
|
|
265
|
+
}, 15_000);
|
|
266
|
+
const cleanup = () => {
|
|
267
|
+
clearTimeout(timeout);
|
|
268
|
+
this.readyResolver = null;
|
|
269
|
+
child.off('exit', onExit);
|
|
270
|
+
child.off('error', onError);
|
|
271
|
+
};
|
|
272
|
+
this.readyResolver = onReady;
|
|
273
|
+
child.on('exit', onExit);
|
|
274
|
+
child.on('error', onError);
|
|
275
|
+
});
|
|
276
|
+
return this.childReady;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private readyResolver: (() => void) | null = null;
|
|
280
|
+
|
|
281
|
+
private handleLine(line: string): void {
|
|
282
|
+
let msg: { op?: string; id?: string; name?: string; args?: unknown; stdout?: string; stderr?: string; return_code?: number };
|
|
283
|
+
try {
|
|
284
|
+
msg = JSON.parse(line);
|
|
285
|
+
} catch {
|
|
286
|
+
console.error(`[pytc:${this.label}] non-protocol stdout line: ${line.slice(0, 200)}`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
switch (msg.op) {
|
|
291
|
+
case 'ready':
|
|
292
|
+
this.readyResolver?.();
|
|
293
|
+
return;
|
|
294
|
+
|
|
295
|
+
case 'tool_call': {
|
|
296
|
+
const callId = msg.id;
|
|
297
|
+
const toolName = msg.name;
|
|
298
|
+
if (!callId || !toolName) return;
|
|
299
|
+
const args =
|
|
300
|
+
msg.args && typeof msg.args === 'object' && !Array.isArray(msg.args)
|
|
301
|
+
? (msg.args as Record<string, unknown>)
|
|
302
|
+
: {};
|
|
303
|
+
this.onToolCall(toolName, args)
|
|
304
|
+
.catch((err) => `Error: ${err instanceof Error ? err.message : String(err)}`)
|
|
305
|
+
.then((result) => {
|
|
306
|
+
this.send({ op: 'tool_result', id: callId, result });
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case 'exec_result': {
|
|
312
|
+
if (this.pending && msg.id === this.pending.id) {
|
|
313
|
+
this.settlePending({
|
|
314
|
+
stdout: msg.stdout ?? '',
|
|
315
|
+
stderr: msg.stderr ?? '',
|
|
316
|
+
returnCode: typeof msg.return_code === 'number' ? msg.return_code : 1,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
default:
|
|
323
|
+
console.error(`[pytc:${this.label}] unknown protocol op: ${String(msg.op)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private settlePending(result: ExecResult): void {
|
|
328
|
+
const pending = this.pending;
|
|
329
|
+
if (!pending || pending.settled) return;
|
|
330
|
+
pending.settled = true;
|
|
331
|
+
if (pending.deadlineTimer) clearTimeout(pending.deadlineTimer);
|
|
332
|
+
if (pending.killTimer) clearTimeout(pending.killTimer);
|
|
333
|
+
this.pending = null;
|
|
334
|
+
pending.resolve(result);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private send(obj: unknown): void {
|
|
338
|
+
const child = this.child;
|
|
339
|
+
if (!child || child.exitCode !== null || child.killed) return;
|
|
340
|
+
try {
|
|
341
|
+
child.stdin.write(JSON.stringify(obj) + '\n');
|
|
342
|
+
} catch (err) {
|
|
343
|
+
console.error(
|
|
344
|
+
`[pytc:${this.label}] protocol write failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Kill interpreter + clean temp dir. State is lost (by design). */
|
|
350
|
+
private reclaim(reason: string): void {
|
|
351
|
+
if (this.child) {
|
|
352
|
+
console.error(`[pytc:${this.label}] reclaiming python interpreter (${reason})`);
|
|
353
|
+
}
|
|
354
|
+
this.teardownChild();
|
|
355
|
+
this.clearIdleTimer();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private teardownChild(): void {
|
|
359
|
+
if (this.reader) {
|
|
360
|
+
this.reader.close();
|
|
361
|
+
this.reader = null;
|
|
362
|
+
}
|
|
363
|
+
if (this.child) {
|
|
364
|
+
const child = this.child;
|
|
365
|
+
this.child = null;
|
|
366
|
+
this.childReady = null;
|
|
367
|
+
this.readyResolver = null;
|
|
368
|
+
child.removeAllListeners('exit');
|
|
369
|
+
try {
|
|
370
|
+
child.stdin.end();
|
|
371
|
+
} catch {
|
|
372
|
+
// stream may already be destroyed
|
|
373
|
+
}
|
|
374
|
+
if (child.exitCode === null && !child.killed) {
|
|
375
|
+
child.kill('SIGKILL');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (this.runtimeDir) {
|
|
379
|
+
try {
|
|
380
|
+
rmSync(this.runtimeDir, { recursive: true, force: true });
|
|
381
|
+
} catch {
|
|
382
|
+
// temp cleanup is best-effort
|
|
383
|
+
}
|
|
384
|
+
this.runtimeDir = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private armIdleTimer(): void {
|
|
389
|
+
this.clearIdleTimer();
|
|
390
|
+
if (this.idleReclaimMs <= 0 || !this.child) return;
|
|
391
|
+
this.idleTimer = setTimeout(() => {
|
|
392
|
+
if (!this.pending) this.reclaim('idle');
|
|
393
|
+
}, this.idleReclaimMs);
|
|
394
|
+
// Do not hold the process open just to reclaim an idle interpreter.
|
|
395
|
+
this.idleTimer.unref?.();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private clearIdleTimer(): void {
|
|
399
|
+
if (this.idleTimer) {
|
|
400
|
+
clearTimeout(this.idleTimer);
|
|
401
|
+
this.idleTimer = null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Map framework tool names to python identifiers: `--` becomes `__`.
|
|
408
|
+
* Names that still aren't valid identifiers, or that collide after
|
|
409
|
+
* sanitization, are skipped loudly — a skipped tool is unreachable from
|
|
410
|
+
* scripts (the exact-name tools[...] dict is built from this same list).
|
|
411
|
+
*/
|
|
412
|
+
export function buildInjectedTools(
|
|
413
|
+
toolNames: string[],
|
|
414
|
+
log: (message: string) => void = (m) => console.error(m),
|
|
415
|
+
): InjectedTool[] {
|
|
416
|
+
const seen = new Map<string, string>();
|
|
417
|
+
const injected: InjectedTool[] = [];
|
|
418
|
+
for (const toolName of toolNames) {
|
|
419
|
+
const pyName = toolName.replace(/--/g, '__');
|
|
420
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(pyName)) {
|
|
421
|
+
log(`[pytc] tool '${toolName}' skipped: '${pyName}' is not a valid python identifier`);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const existing = seen.get(pyName);
|
|
425
|
+
if (existing) {
|
|
426
|
+
log(`[pytc] tool '${toolName}' skipped: python name '${pyName}' collides with '${existing}'`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
seen.set(pyName, toolName);
|
|
430
|
+
injected.push({ pyName, toolName });
|
|
431
|
+
}
|
|
432
|
+
return injected;
|
|
433
|
+
}
|