@hncode/hncode 0.6.7 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config.js +26 -0
- package/src/llm.js +7 -0
- package/src/ptc-worker.js +120 -0
- package/src/ptc.js +141 -0
- package/src/settings-schema.js +12 -0
- package/src/tools/index.js +2 -0
- package/src/tools/ptc.js +91 -0
- package/src/tui.js +53 -1
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -370,6 +370,27 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
370
370
|
|| root.max_output_size
|
|
371
371
|
|| (modelMaxOutput > 0 ? modelMaxOutput : DEFAULTS.maxOutputTokens),
|
|
372
372
|
);
|
|
373
|
+
// A NUMBER OR NOTHING: an unparseable value falls back to "not set", which means the provider decides.
|
|
374
|
+
const numOrNull = (v, min, max) => {
|
|
375
|
+
if (v == null || v === '') return null;
|
|
376
|
+
const n = Number(v);
|
|
377
|
+
if (!Number.isFinite(n)) return null;
|
|
378
|
+
return Math.max(min, Math.min(max, n));
|
|
379
|
+
};
|
|
380
|
+
// `sampling` gates the whole group. A provider that rejects `temperature` outright — several refuse it for
|
|
381
|
+
// reasoning models — needs a switch, not a magic value nobody can find.
|
|
382
|
+
const samplingOn = process.env.HNCODE_SAMPLING
|
|
383
|
+
? !/^(0|false|no|off)$/i.test(process.env.HNCODE_SAMPLING)
|
|
384
|
+
: root.sampling !== false;
|
|
385
|
+
const temperature = samplingOn
|
|
386
|
+
? numOrNull(process.env.HNCODE_TEMPERATURE ?? root.temperature, 0, 2)
|
|
387
|
+
: null;
|
|
388
|
+
const topP = samplingOn
|
|
389
|
+
? numOrNull(process.env.HNCODE_TOP_P ?? root.top_p, 0, 1)
|
|
390
|
+
: null;
|
|
391
|
+
const presencePenalty = samplingOn
|
|
392
|
+
? numOrNull(process.env.HNCODE_PRESENCE_PENALTY ?? root.presence_penalty, -2, 2)
|
|
393
|
+
: null;
|
|
373
394
|
const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
|
|
374
395
|
const allowExternal = process.env.HNCODE_ALLOW_EXTERNAL === '1' || !!root.tool_allow_external_paths;
|
|
375
396
|
|
|
@@ -377,6 +398,11 @@ const b = baseUrl.replace(/\/+$/, '');
|
|
|
377
398
|
model, provider: providerName, innerModel,
|
|
378
399
|
baseUrl: b, endpoint, apiKey, protocol,
|
|
379
400
|
maxContextTokens: maxContext, maxOutputTokens: maxOutput, reasoning,
|
|
401
|
+
// Sampling. `undefined`/null means "do not send it", which is what lets a provider apply its own
|
|
402
|
+
// default — and what keeps a rejection from becoming a hard failure.
|
|
403
|
+
temperature: temperature == null ? undefined : temperature,
|
|
404
|
+
topP: topP == null ? undefined : topP,
|
|
405
|
+
presencePenalty: presencePenalty == null ? undefined : presencePenalty,
|
|
380
406
|
workspace: process.env.HNCODE_WORKSPACE || root.workspace || DEFAULTS.workspace,
|
|
381
407
|
allowExternal,
|
|
382
408
|
// Standing permission rules (see permissions.js). `[permissions]` in config.toml:
|
package/src/llm.js
CHANGED
|
@@ -233,6 +233,8 @@ function authHeaders(cfg) {
|
|
|
233
233
|
function buildBody(cfg, messages, streaming = true, opts = {}) {
|
|
234
234
|
const common = { stream: streaming, max_tokens: cfg.maxOutputTokens };
|
|
235
235
|
if (cfg.temperature != null) common.temperature = cfg.temperature;
|
|
236
|
+
if (cfg.topP != null) common.top_p = cfg.topP;
|
|
237
|
+
if (cfg.presencePenalty != null) common.presence_penalty = cfg.presencePenalty;
|
|
236
238
|
// Ask for the token accounting on the FINAL stream frame. Without this the
|
|
237
239
|
// OpenAI-compatible stream carries no `usage` at all, so the only token numbers
|
|
238
240
|
// available were our own estimates (see /usage, which divided the transcript's
|
|
@@ -265,6 +267,9 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
|
|
|
265
267
|
...common, ...wire,
|
|
266
268
|
};
|
|
267
269
|
if (!body.tools || !body.tools.length) delete body.tools;
|
|
270
|
+
// NO PRESENCE PENALTY ON THIS PROTOCOL: it has no equivalent, and an unknown field is an error
|
|
271
|
+
// here rather than something quietly ignored.
|
|
272
|
+
delete body.presence_penalty;
|
|
268
273
|
// Prompt caching: mark the stable prefix (tools + system + conversation head)
|
|
269
274
|
// so the provider bills it as a cache READ instead of fresh input. The key is
|
|
270
275
|
// the SESSION id, so it stays identical across turns and never thrashes.
|
|
@@ -287,6 +292,8 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
|
|
|
287
292
|
};
|
|
288
293
|
if (instructions) rbody.instructions = instructions;
|
|
289
294
|
if (cfg.temperature != null) rbody.temperature = cfg.temperature;
|
|
295
|
+
if (cfg.topP != null) rbody.top_p = cfg.topP;
|
|
296
|
+
// No presence penalty on this protocol: sending it is an unknown-parameter error, not a hint.
|
|
290
297
|
if (!opts.noTools) {
|
|
291
298
|
const defsR = Object.prototype.hasOwnProperty.call(opts, 'tools')
|
|
292
299
|
? responsesToolDefs(opts.tools) : responsesToolDefs(llmToolsList);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The PTC executor's WORKER SIDE — what actually runs the model's code.
|
|
2
|
+
//
|
|
3
|
+
// This file is loaded as a `worker_threads` entry, never imported by the main process. Everything it can reach is
|
|
4
|
+
// what `parentPort` hands it and the globals below: no `require`, no `process` access beyond what is passed in,
|
|
5
|
+
// and no filesystem. The model's code is compiled inside a `vm` context whose only interesting binding is
|
|
6
|
+
// `callTool`, which messages the main thread and waits.
|
|
7
|
+
//
|
|
8
|
+
// WHY A WORKER AND NOT vm ALONE: `vm` is not a security boundary — code in a context can still reach the host
|
|
9
|
+
// through a prototype it was handed. The thread is what makes a runaway loop killable: `worker.terminate()` stops
|
|
10
|
+
// it without the main process ever yielding, which a `while(true)` in `vm.runInContext` would not.
|
|
11
|
+
//
|
|
12
|
+
// The code runs as an ASYNC body, so `await callTool(...)` works exactly as it reads.
|
|
13
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
14
|
+
import vm from 'node:vm';
|
|
15
|
+
|
|
16
|
+
const TOOL_NAMES = Array.isArray(workerData && workerData.toolNames) ? workerData.toolNames : [];
|
|
17
|
+
const TIMEOUT_MS = Number(workerData && workerData.timeoutMs) > 0 ? workerData.timeoutMs : 120000;
|
|
18
|
+
|
|
19
|
+
let seq = 0;
|
|
20
|
+
const pending = new Map();
|
|
21
|
+
|
|
22
|
+
/** Ask the main thread to run a tool, and wait for its answer. */
|
|
23
|
+
function callTool(name, args) {
|
|
24
|
+
const id = ++seq;
|
|
25
|
+
parentPort.postMessage({ type: 'tool', id, name, args: args == null ? {} : args });
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
pending.set(id, { resolve, reject });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
parentPort.on('message', (m) => {
|
|
32
|
+
if (!m || m.type !== 'tool_result') return;
|
|
33
|
+
const p = pending.get(m.id);
|
|
34
|
+
if (!p) return;
|
|
35
|
+
pending.delete(m.id);
|
|
36
|
+
if (m.ok) p.resolve(m.result);
|
|
37
|
+
else p.reject(new Error(String(m.error || 'tool failed')));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ---- the sandbox ---------------------------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** Collect printed lines. `print` is what the model reaches for. */
|
|
43
|
+
const out = [];
|
|
44
|
+
const write = (...parts) => {
|
|
45
|
+
const line = parts.map((p) => {
|
|
46
|
+
if (typeof p === 'string') return p;
|
|
47
|
+
try { return JSON.stringify(p); } catch { return String(p); }
|
|
48
|
+
}).join(' ');
|
|
49
|
+
// A single line cap keeps a runaway from filling memory before the byte cap is checked.
|
|
50
|
+
out.push(line.length > 8000 ? line.slice(0, 8000) + '…' : line);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const sandbox = {
|
|
54
|
+
callTool,
|
|
55
|
+
print: write,
|
|
56
|
+
console: { log: write, error: write, warn: write, info: write },
|
|
57
|
+
JSON,
|
|
58
|
+
Math,
|
|
59
|
+
Date,
|
|
60
|
+
Promise,
|
|
61
|
+
Array,
|
|
62
|
+
Object,
|
|
63
|
+
String,
|
|
64
|
+
Number,
|
|
65
|
+
Boolean,
|
|
66
|
+
Map,
|
|
67
|
+
Set,
|
|
68
|
+
RegExp,
|
|
69
|
+
Error,
|
|
70
|
+
TypeError,
|
|
71
|
+
RangeError,
|
|
72
|
+
isNaN,
|
|
73
|
+
isFinite,
|
|
74
|
+
parseInt,
|
|
75
|
+
parseFloat,
|
|
76
|
+
// `sleep` is the one timer worth exposing: pacing between calls is a real need, and `setTimeout` would let the
|
|
77
|
+
// code keep the thread alive past the point where the caller wants an answer.
|
|
78
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, Math.max(0, Math.min(Number(ms) || 0, 10000)))),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const context = vm.createContext(sandbox, {
|
|
82
|
+
// No `codeGeneration` from strings inside the sandbox: a `new Function` there would be a second, unreviewed way
|
|
83
|
+
// in — and the body the model writes is already compiled below.
|
|
84
|
+
codeGeneration: { strings: false, wasm: false },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
async function run(code) {
|
|
88
|
+
let script;
|
|
89
|
+
try {
|
|
90
|
+
script = new vm.Script(`(async () => {\n${code}\n})()`, { filename: 'ptc-sandbox.js' });
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return { ok: false, error: `Syntax error: ${e.message}`, output: '' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
parentPort.postMessage({ type: 'timeout', ms: TIMEOUT_MS });
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}, TIMEOUT_MS);
|
|
99
|
+
if (timer.unref) timer.unref();
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const value = await script.runInContext(context, { timeout: TIMEOUT_MS });
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
// The returned value is what `return` produced, if anything; `print` already captured stdout.
|
|
105
|
+
let returned;
|
|
106
|
+
if (value !== undefined) {
|
|
107
|
+
try { returned = typeof value === 'string' ? value : JSON.stringify(value); } catch { returned = String(value); }
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, output: out.join('\n'), returned };
|
|
110
|
+
} catch (e) {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
return { ok: false, error: `${e && e.name ? e.name + ': ' : ''}${e && e.message || e}`, output: out.join('\n') };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
parentPort.on('message', async (m) => {
|
|
117
|
+
if (!m || m.type !== 'run') return;
|
|
118
|
+
const result = await run(String(m.code || ''));
|
|
119
|
+
parentPort.postMessage({ type: 'done', result });
|
|
120
|
+
});
|
package/src/ptc.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// The PTC executor's MAIN side.
|
|
2
|
+
//
|
|
3
|
+
// Owns a worker, forwards the tool calls it makes to the real tool layer, and enforces the limits the sandbox
|
|
4
|
+
// cannot enforce about itself: output size, tool count, and a wall-clock deadline that survives a wedged await.
|
|
5
|
+
//
|
|
6
|
+
// WHY THE TOOL CALLS COME BACK HERE AT ALL: the tools need `ctx` — the workspace, the approval hook, the session
|
|
7
|
+
// — none of which may exist inside the sandbox. The worker sends `{type:'tool', name, args}`, this side runs the
|
|
8
|
+
// real tool through `getTool`, and the result crosses back as data. The model's code never holds a function that
|
|
9
|
+
// can reach the filesystem; it holds a promise that the host fulfils.
|
|
10
|
+
import { Worker } from 'node:worker_threads';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
/** Tools whose results are safe and useful to hand to code, and whose invocation needs no UI. */
|
|
17
|
+
const CALLABLE = new Set([
|
|
18
|
+
'Read', 'FileLines', 'Glob', 'Grep', 'Git', 'FetchURL', 'WebSearch',
|
|
19
|
+
'TodoList', 'TaskList', 'TaskOutput', 'Memory', 'ListTeammates', 'TeamTaskList',
|
|
20
|
+
'ActivateSkill', 'Goal', 'Diagnostics',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_LIMITS = {
|
|
24
|
+
// A code body that orchestrates twenty calls is already unusual; five hundred is a loop that has gone wrong.
|
|
25
|
+
maxToolCalls: 500,
|
|
26
|
+
// What comes back is the whole point of PTC — a cap keeps a runaway `print` from eating the context it saved.
|
|
27
|
+
maxOutputBytes: 32000,
|
|
28
|
+
timeoutMs: 120000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const CALLABLE_TOOLS = [...CALLABLE];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run `code` in a sandbox, letting it call the tools in `CALLABLE`.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} code An async function BODY. `await callTool(name, args)` is available.
|
|
37
|
+
* @param {object} ctx The tool context, passed straight through to each tool.
|
|
38
|
+
* @param {object} [limits] Overrides for `DEFAULT_LIMITS`.
|
|
39
|
+
* @returns {Promise<{ok: boolean, output: string, returned?: string, error?: string, calls: string[], truncated?: boolean}>}
|
|
40
|
+
*/
|
|
41
|
+
export function runProgrammatic(code, ctx, limits = {}) {
|
|
42
|
+
const lim = { ...DEFAULT_LIMITS, ...limits };
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let settled = false;
|
|
45
|
+
const calls = [];
|
|
46
|
+
const lines = [];
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
let truncated = false;
|
|
49
|
+
|
|
50
|
+
const finish = (result) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
clearTimeout(wall);
|
|
54
|
+
try { worker.terminate(); } catch { /* already gone */ }
|
|
55
|
+
resolve(result);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const worker = new Worker(path.join(here, 'ptc-worker.js'), {
|
|
59
|
+
workerData: { toolNames: [...CALLABLE], timeoutMs: lim.timeoutMs },
|
|
60
|
+
// The sandbox needs no stdio and no shared memory.
|
|
61
|
+
stdout: true,
|
|
62
|
+
stderr: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// The wall clock is the OUTER guarantee: if the worker is stuck in a `while(true)` the inner vm timeout can
|
|
66
|
+
// still fire, but if the main thread is waiting on a tool that never answers, only this one saves us.
|
|
67
|
+
const wall = setTimeout(() => {
|
|
68
|
+
finish({ ok: false, error: `timed out after ${lim.timeoutMs}ms`, output: lines.join('\n'), calls });
|
|
69
|
+
}, lim.timeoutMs + 5000);
|
|
70
|
+
|
|
71
|
+
worker.on('message', async (m) => {
|
|
72
|
+
if (!m) return;
|
|
73
|
+
|
|
74
|
+
if (m.type === 'tool') {
|
|
75
|
+
if (calls.length >= lim.maxToolCalls) {
|
|
76
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: `tool-call limit reached (${lim.maxToolCalls})` });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const name = String(m.name || '');
|
|
80
|
+
if (!CALLABLE.has(name)) {
|
|
81
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: `${name} is not callable from code` });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Imported here, not at the top: tools/index.js imports the PTC TOOL, which imports this module — a static
|
|
85
|
+
// import would close that cycle and read `spec` before it exists.
|
|
86
|
+
const { getTool } = await import('./tools/index.js');
|
|
87
|
+
const tool = getTool(name);
|
|
88
|
+
if (!tool || typeof tool.execute !== 'function') {
|
|
89
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: `no such tool: ${name}` });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
calls.push(name);
|
|
93
|
+
try {
|
|
94
|
+
const raw = await tool.execute(m.args || {}, ctx);
|
|
95
|
+
// A media result is structured blocks, not text; code cannot do anything useful with it.
|
|
96
|
+
const value = (raw && typeof raw === 'object' && Array.isArray(raw.media))
|
|
97
|
+
? '[media result omitted]'
|
|
98
|
+
: (typeof raw === 'string' ? raw : JSON.stringify(raw));
|
|
99
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: true, result: value });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
worker.postMessage({ type: 'tool_result', id: m.id, ok: false, error: String((e && e.message) || e) });
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (m.type === 'timeout') {
|
|
107
|
+
finish({ ok: false, error: `timed out after ${lim.timeoutMs}ms`, output: lines.join('\n'), calls });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (m.type === 'done') {
|
|
112
|
+
const r = m.result || {};
|
|
113
|
+
let output = String(r.output == null ? '' : r.output);
|
|
114
|
+
// Cap by BYTES, not characters: the model pays for tokens, and a megabyte of ASCII is the worst case.
|
|
115
|
+
if (Buffer.byteLength(output, 'utf8') > lim.maxOutputBytes) {
|
|
116
|
+
output = output.slice(0, lim.maxOutputBytes);
|
|
117
|
+
truncated = true;
|
|
118
|
+
}
|
|
119
|
+
finish({
|
|
120
|
+
ok: !!r.ok,
|
|
121
|
+
output,
|
|
122
|
+
returned: r.returned,
|
|
123
|
+
error: r.error,
|
|
124
|
+
calls,
|
|
125
|
+
truncated: truncated || undefined,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
worker.on('error', (e) => {
|
|
131
|
+
finish({ ok: false, error: String((e && e.message) || e), output: lines.join('\n'), calls });
|
|
132
|
+
});
|
|
133
|
+
worker.on('exit', (code) => {
|
|
134
|
+
// An exit without a `done` is the worker dying: report it rather than hanging the caller for ever.
|
|
135
|
+
if (!settled) finish({ ok: false, error: `sandbox exited with code ${code}`, output: lines.join('\n'), calls });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
worker.postMessage({ type: 'run', code: String(code || '') });
|
|
139
|
+
void bytes;
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/settings-schema.js
CHANGED
|
@@ -125,6 +125,18 @@ export const SETTINGS_SCHEMA = [
|
|
|
125
125
|
ui: { tab: 'model', group: 'Thinking', label: 'Context window', hint: 'Override the model window when the catalog is wrong; 0 means use it' } },
|
|
126
126
|
{ key: 'prompt_cache', ...bool(true),
|
|
127
127
|
ui: { tab: 'model', group: 'Provider', label: 'Prompt caching', hint: 'Ask the provider to cache the request prefix' } },
|
|
128
|
+
{ key: 'sampling', ...bool(true),
|
|
129
|
+
label: 'Send sampling parameters',
|
|
130
|
+
help: 'Off sends no temperature/top_p at all, for a provider that rejects them (several do, for reasoning models).' },
|
|
131
|
+
{ key: 'temperature', ...str(''),
|
|
132
|
+
label: 'Temperature',
|
|
133
|
+
help: '0 is deterministic, 2 is wild. Empty leaves the provider default. Accepts 0-2 with one decimal.' },
|
|
134
|
+
{ key: 'top_p', ...str(''),
|
|
135
|
+
label: 'Top P',
|
|
136
|
+
help: 'Nucleus sampling 0-1. Empty leaves the provider default. Usually set INSTEAD of temperature, not both.' },
|
|
137
|
+
{ key: 'presence_penalty', ...str(''),
|
|
138
|
+
label: 'Presence penalty',
|
|
139
|
+
help: 'OpenAI-compatible only: -2 to 2, positive discourages repeating topics. Anthropic has no equivalent and never receives it.' },
|
|
128
140
|
|
|
129
141
|
// ---- context ------------------------------------------------------------
|
|
130
142
|
{ key: 'auto_compact', ...bool(true),
|
package/src/tools/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { spec as diagnosticsSpec } from './diagnostics.js';
|
|
|
24
24
|
import * as memoryMod from './memory.js';
|
|
25
25
|
import * as goalMod from './goal.js';
|
|
26
26
|
import * as activateSkillMod from './activate-skill.js';
|
|
27
|
+
import * as ptcMod from './ptc.js';
|
|
27
28
|
import { pluginTools, pluginToolGeneration } from '../plugin.js';
|
|
28
29
|
|
|
29
30
|
|
|
@@ -59,6 +60,7 @@ const builtinTools = [
|
|
|
59
60
|
memoryMod.spec,
|
|
60
61
|
goalMod.spec,
|
|
61
62
|
activateSkillMod.spec,
|
|
63
|
+
ptcMod.spec,
|
|
62
64
|
...TEAM_TOOL_SPECS,
|
|
63
65
|
];
|
|
64
66
|
|
package/src/tools/ptc.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgrammaticToolCall — let the model orchestrate tools with code instead of one round trip each.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM IT SOLVES: a task like "which of these forty files reference the removed symbol" is forty calls,
|
|
5
|
+
* each with its own inference pass, and every one of those results then sits in the context whether or not it
|
|
6
|
+
* mattered. The reading is mechanical; only the conclusion is worth the tokens.
|
|
7
|
+
*
|
|
8
|
+
* With this tool the model writes the LOOP, and only what it prints comes back. The sandbox holds the forty
|
|
9
|
+
* results, the filtering and the joining; the conversation holds the six files that matched.
|
|
10
|
+
*
|
|
11
|
+
* WHY A SEPARATE TOOL AND NOT `Bash`: a shell would give the model the whole machine and no structure. Here every
|
|
12
|
+
* call goes back through the tool layer — the same approval rules, the same workspace guard, the same tools —
|
|
13
|
+
* and the code can only reach the ones on the allowlist. See src/ptc.js for that list, and ptc-worker.js for the
|
|
14
|
+
* sandbox itself.
|
|
15
|
+
*/
|
|
16
|
+
import { runProgrammatic, CALLABLE_TOOLS } from '../ptc.js';
|
|
17
|
+
|
|
18
|
+
const DESCRIPTION = [
|
|
19
|
+
'Orchestrate tools with JavaScript instead of calling them one at a time.',
|
|
20
|
+
'',
|
|
21
|
+
'WHEN TO USE IT: work that is a LOOP over tool results, or that needs intermediate data the model should not',
|
|
22
|
+
'have to read. Reading twenty files to find the three that mention a symbol; checking every changed file for a',
|
|
23
|
+
'pattern; collecting values from many calls and printing only the ones that matter. The intermediate results',
|
|
24
|
+
'stay inside the sandbox and never enter the conversation — that is the point.',
|
|
25
|
+
'',
|
|
26
|
+
'WHEN NOT TO USE IT: one or two calls, or when you need to READ a result and decide what to do next. Then an',
|
|
27
|
+
'ordinary tool call is shorter and clearer.',
|
|
28
|
+
'',
|
|
29
|
+
'HOW TO WRITE IT: the body of an async function. `await callTool(name, args)` runs a tool and resolves to its',
|
|
30
|
+
'text result. `print(...)` writes a line to the output; `console.log` is the same. Whatever you `return` is',
|
|
31
|
+
'appended. Nothing reaches the conversation except what you print or return.',
|
|
32
|
+
'',
|
|
33
|
+
'Example:',
|
|
34
|
+
'```',
|
|
35
|
+
"const hits = [];",
|
|
36
|
+
"for (const f of (await callTool('Glob', { pattern: 'src/**/*.js' })).split('\\n').filter(Boolean)) {",
|
|
37
|
+
" const text = await callTool('Grep', { pattern: 'oldSymbol', path: f });",
|
|
38
|
+
" if (text && !/No matches/.test(text)) hits.push(f);",
|
|
39
|
+
'}',
|
|
40
|
+
'print(`${hits.length} files still reference it`);',
|
|
41
|
+
'print(hits.join(\'\\n\'));',
|
|
42
|
+
'```',
|
|
43
|
+
'',
|
|
44
|
+
`CALLABLE TOOLS: ${CALLABLE_TOOLS.join(', ')}.`,
|
|
45
|
+
'Anything else is refused — a shell or a write would escape the sandbox\'s purpose.',
|
|
46
|
+
].join('\n');
|
|
47
|
+
|
|
48
|
+
export const spec = {
|
|
49
|
+
name: 'ProgrammaticToolCall',
|
|
50
|
+
description: DESCRIPTION,
|
|
51
|
+
parameters: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
code: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: [
|
|
57
|
+
'The body of an async function. `await callTool(name, args)` calls a tool; `print(x)` emits a line.',
|
|
58
|
+
'Only what is printed or returned reaches the conversation.',
|
|
59
|
+
].join(' '),
|
|
60
|
+
},
|
|
61
|
+
purpose: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'One line naming what the code is for, shown in the transcript.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['code'],
|
|
67
|
+
},
|
|
68
|
+
async execute(args, ctx) {
|
|
69
|
+
const code = String((args && args.code) || '');
|
|
70
|
+
if (!code.trim()) return 'Error: `code` is empty.';
|
|
71
|
+
|
|
72
|
+
const r = await runProgrammatic(code, ctx);
|
|
73
|
+
const parts = [];
|
|
74
|
+
|
|
75
|
+
if (r.output) parts.push(r.output);
|
|
76
|
+
if (r.returned != null && String(r.returned).trim()) parts.push(String(r.returned));
|
|
77
|
+
if (r.truncated) parts.push('[output truncated]');
|
|
78
|
+
|
|
79
|
+
if (!r.ok) {
|
|
80
|
+
// The error goes LAST, after whatever the code managed to print: a partial result plus the reason it
|
|
81
|
+
// stopped is more useful than the reason alone.
|
|
82
|
+
parts.push(`Error: ${r.error}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// How many tools ran is worth stating — it is the saving this feature exists for, and a model that sees 0 is
|
|
86
|
+
// being told its code never reached a tool.
|
|
87
|
+
if (r.calls.length) parts.push(`(${r.calls.length} tool call${r.calls.length === 1 ? '' : 's'})`);
|
|
88
|
+
|
|
89
|
+
return parts.join('\n') || '(no output)';
|
|
90
|
+
},
|
|
91
|
+
};
|
package/src/tui.js
CHANGED
|
@@ -187,6 +187,7 @@ export const COMMANDS = [
|
|
|
187
187
|
{ name: 'permission', desc: 'Select permission mode', priority: 100 },
|
|
188
188
|
{ name: 'settings', aliases: ['config'], desc: 'Browse and change every setting, grouped by area', priority: 100, argumentHint: '[tab|filter]' },
|
|
189
189
|
{ name: 'set', desc: 'Set one config value by key, or list what differs from the default', priority: 100, argumentHint: '[<key> [value]]' },
|
|
190
|
+
{ name: 'sampling', desc: 'Show or set temperature, top_p and presence_penalty', priority: 100, argumentHint: '[on|off|temperature <n>|top_p <n>|presence_penalty <n>]' },
|
|
190
191
|
{ name: 'plan', desc: 'Toggle plan mode', priority: 100, argumentHint: '[on|off|clear]' },
|
|
191
192
|
{ name: 'websearch-api', desc: 'Edit the WebSearch API keys (one per line, tried in order)', priority: 96 },
|
|
192
193
|
{ name: 'focus', desc: 'Toggle Focus mode (minimal tools first, full tools after)', priority: 98, argumentHint: '[on|off]' },
|
|
@@ -8688,12 +8689,26 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8688
8689
|
if (key === 'calm_mode') cfg.calmMode = v;
|
|
8689
8690
|
if (key === 'swarm_mode') cfg.swarm = v;
|
|
8690
8691
|
if (key === 'tool_allow_external_paths') cfg.allowExternal = v;
|
|
8692
|
+
if (key === 'sampling' && !v) {
|
|
8693
|
+
// Off means NONE of them is sent — not their defaults, which is what a provider rejecting them
|
|
8694
|
+
// would receive otherwise.
|
|
8695
|
+
cfg.temperature = undefined; cfg.topP = undefined; cfg.presencePenalty = undefined;
|
|
8696
|
+
}
|
|
8691
8697
|
} else {
|
|
8692
8698
|
cfg[key] = raw; cfg.raw[key] = raw;
|
|
8693
8699
|
if (key === 'compact_threshold') cfg.compactThreshold = Number(raw) / 100;
|
|
8694
8700
|
if (key === 'compact_keep_ratio') cfg.compactKeepRatio = Number(raw) / 100;
|
|
8695
8701
|
if (key === 'trim_threshold') cfg.trimThreshold = Number(raw) / 100;
|
|
8696
8702
|
if (key === 'trim_keep_ratio') cfg.trimKeepRatio = Number(raw) / 100;
|
|
8703
|
+
// SAMPLING: a NUMBER, or gone. The generic writer stores the typed string, and `llm.js` sends it
|
|
8704
|
+
// straight into the request — a provider given `"0.3"` rejects it or ignores it.
|
|
8705
|
+
if (key === 'temperature' || key === 'top_p' || key === 'presence_penalty') {
|
|
8706
|
+
const field = { temperature: 'temperature', top_p: 'topP', presence_penalty: 'presencePenalty' }[key];
|
|
8707
|
+
const n = raw === '' ? null : Number(raw);
|
|
8708
|
+
// Clearing the field removes the parameter, so the provider applies its own default — which is not
|
|
8709
|
+
// the same thing as sending 0.
|
|
8710
|
+
cfg[field] = n == null || !Number.isFinite(n) ? undefined : n;
|
|
8711
|
+
}
|
|
8697
8712
|
if (key === 'max_context_tokens') cfg.maxContextTokens = Number(raw) || cfg.maxContextTokens;
|
|
8698
8713
|
if (key === 'provider') cfg.provider = raw;
|
|
8699
8714
|
if (key === 'model') { cfg.model = raw; state.modelLabel = raw; }
|
|
@@ -8942,6 +8957,42 @@ export async function dispatch(cmdRaw, arg, state, cfg, session, h, submit, stdo
|
|
|
8942
8957
|
}
|
|
8943
8958
|
|
|
8944
8959
|
|
|
8960
|
+
|
|
8961
|
+
case 'sampling': {
|
|
8962
|
+
const parts = String(raw || '').trim().split(/\s+/).filter(Boolean);
|
|
8963
|
+
const show = () => {
|
|
8964
|
+
const row = (label, v, note) => ` ${label.padEnd(18)} ${v == null ? '(provider default)' : v}${note ? ' ' + note : ''}`;
|
|
8965
|
+
sayPanel([
|
|
8966
|
+
'Sampling parameters',
|
|
8967
|
+
'',
|
|
8968
|
+
row('send them', cfg.sampling === false ? 'off' : 'on', cfg.sampling === false ? 'nothing is sent' : ''),
|
|
8969
|
+
row('temperature', cfg.temperature),
|
|
8970
|
+
row('top_p', cfg.topP),
|
|
8971
|
+
row('presence_penalty', cfg.presencePenalty, 'OpenAI-compatible only'),
|
|
8972
|
+
'',
|
|
8973
|
+
'Set with /sampling temperature 0.3, /sampling top_p 0.9, /sampling off',
|
|
8974
|
+
].join('\n'));
|
|
8975
|
+
};
|
|
8976
|
+
if (!parts.length) { show(); return; }
|
|
8977
|
+
if (parts[0] === 'on' || parts[0] === 'off') {
|
|
8978
|
+
applySetting('sampling', parts[0] === 'on' ? 'true' : 'false');
|
|
8979
|
+
app(`Sampling: ${parts[0]}`);
|
|
8980
|
+
show();
|
|
8981
|
+
return;
|
|
8982
|
+
}
|
|
8983
|
+
const key = parts[0] === 'top-p' ? 'top_p' : parts[0] === 'presence-penalty' ? 'presence_penalty' : parts[0];
|
|
8984
|
+
if (!['temperature', 'top_p', 'presence_penalty'].includes(key)) {
|
|
8985
|
+
appErr('Usage: /sampling [on|off] | temperature <0-2> | top_p <0-1> | presence_penalty <-2-2>');
|
|
8986
|
+
return;
|
|
8987
|
+
}
|
|
8988
|
+
if (parts.length === 1) { show(); return; }
|
|
8989
|
+
// An empty value clears it: the field means "let the provider decide", and `0` is a real temperature.
|
|
8990
|
+
const value = parts.slice(1).join(' ').trim();
|
|
8991
|
+
if (!applySetting(key, value)) return;
|
|
8992
|
+
app(value === '' ? `${key} cleared` : `${key} = ${value}`);
|
|
8993
|
+
show();
|
|
8994
|
+
return;
|
|
8995
|
+
}
|
|
8945
8996
|
case 'set': {
|
|
8946
8997
|
// `/set <key> <value>` — the direct path, so any setting is reachable without
|
|
8947
8998
|
// remembering which command owns it. `/set` alone lists what is changed.
|
|
@@ -14140,7 +14191,7 @@ const agents = readAgentsMd(state.cwd || state.workspace || cfg.workspace, state
|
|
|
14140
14191
|
// Tools that act ONLY inside the session: the roster, the mailbox, the task list, the memories.
|
|
14141
14192
|
// `isInsideWorkspace` answers `false` for any tool it does not know, so without this they all asked for
|
|
14142
14193
|
// approval in yolo — the one mode whose point is not asking.
|
|
14143
|
-
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'ActivateSkill', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
|
|
14194
|
+
const safeTools = ['Read', 'Grep', 'Glob', 'FetchURL', 'WebSearch', 'TaskOutput', 'TaskList', 'TaskStop', 'TaskWait', 'TodoList', 'Goal', 'ActivateSkill', 'ProgrammaticToolCall', 'FileLines', 'AgentList', 'AgentMessage', 'AgentInterrupt', 'AgentClose', 'TaskInput', 'SendMessage', 'TeamTaskCreate', 'TeamTaskUpdate', 'TeamTaskList', 'ListTeammates', 'Memory', 'AskUserQuestion'];
|
|
14144
14195
|
if (rule !== 'ask' && safeTools.includes(toolName)) return true;
|
|
14145
14196
|
// A read-only SHELL COMMAND (ls/git status/rg/cat …) is as safe as a
|
|
14146
14197
|
// read-only TOOL, so it runs without a prompt in Ask mode too — the user
|
|
@@ -20567,6 +20618,7 @@ function wheelStep(state, family) {
|
|
|
20567
20618
|
hub.actions.setConfig = async (key, value) => {
|
|
20568
20619
|
const ALLOWED = new Set([
|
|
20569
20620
|
'calm_mode', 'auto_update', 'auto_compact', 'prompt_cache',
|
|
20621
|
+
'temperature', 'top_p', 'presence_penalty', 'sampling',
|
|
20570
20622
|
'tool_allow_external_paths', 'subagent_model', 'swarm_mode', 'theme',
|
|
20571
20623
|
]);
|
|
20572
20624
|
const k = String(key || '');
|