@geraldmaron/construct 1.2.2 → 1.2.3
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/apps/chat/engine/ai-sdk-agent.mjs +183 -0
- package/apps/chat/engine/loop-driver.mjs +211 -0
- package/apps/chat/engine/models.mjs +122 -0
- package/apps/chat/engine/provider-adapters.mjs +171 -0
- package/apps/chat/engine/tools/permission.mjs +54 -0
- package/apps/chat/engine/tools/primitives.mjs +180 -0
- package/apps/chat/engine/tools/registry.mjs +122 -0
- package/apps/chat/engine/turn-controls.mjs +70 -0
- package/bin/construct +52 -12
- package/lib/cli-commands.mjs +1 -3
- package/lib/config/legacy-config-migration.mjs +59 -0
- package/lib/runtime-env.mjs +1 -1
- package/lib/setup.mjs +12 -3
- package/package.json +2 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apps/chat/engine/tools/permission.mjs — the permission and sandbox gate for the
|
|
3
|
+
* owned loop's tools.
|
|
4
|
+
*
|
|
5
|
+
* Owning the loop means Construct, not a host, decides whether a tool runs. This
|
|
6
|
+
* gate reuses the host-agnostic decision vocabulary ADR-0040 defined
|
|
7
|
+
* (allow | allow_always | reject) and the sandbox levels from lib/chat/config.mjs
|
|
8
|
+
* (read-only, workspace-write, danger-full-access). Read-only tools (read, grep,
|
|
9
|
+
* glob) always pass; mutating tools (write, edit, shell) are gated by the sandbox
|
|
10
|
+
* level and the current permission mode, deferring to an interactive
|
|
11
|
+
* `requestPermission` handler only in `ask` mode. `allow_always` is sticky for the
|
|
12
|
+
* rest of the session so a user is not re-prompted per call.
|
|
13
|
+
*
|
|
14
|
+
* `allowOutside` (escape the workspace root) is granted only under
|
|
15
|
+
* danger-full-access; every other level keeps tools inside the workspace, which
|
|
16
|
+
* the primitives enforce independently.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob']);
|
|
20
|
+
export const MUTATING_TOOLS = new Set(['write', 'edit', 'shell']);
|
|
21
|
+
|
|
22
|
+
export function createPermissionGate({
|
|
23
|
+
getSandbox = () => null,
|
|
24
|
+
getPermissionMode = () => 'allow_once',
|
|
25
|
+
requestPermission = null,
|
|
26
|
+
} = {}) {
|
|
27
|
+
let alwaysAllowed = false;
|
|
28
|
+
|
|
29
|
+
async function check(toolName, input = {}) {
|
|
30
|
+
const sandbox = getSandbox() || 'workspace-write';
|
|
31
|
+
const allowOutside = sandbox === 'danger-full-access';
|
|
32
|
+
|
|
33
|
+
if (READ_ONLY_TOOLS.has(toolName)) return { allowed: true, allowOutside };
|
|
34
|
+
|
|
35
|
+
if (sandbox === 'read-only') {
|
|
36
|
+
return { allowed: false, allowOutside, reason: 'sandbox is read-only; mutating tools are disabled (change with /set sandbox workspace-write)' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (alwaysAllowed) return { allowed: true, allowOutside };
|
|
40
|
+
|
|
41
|
+
const mode = getPermissionMode() || 'allow_once';
|
|
42
|
+
if (mode === 'reject') return { allowed: false, allowOutside, reason: 'permission mode is reject' };
|
|
43
|
+
if (mode === 'allow_always') { alwaysAllowed = true; return { allowed: true, allowOutside }; }
|
|
44
|
+
if (mode === 'allow_once') return { allowed: true, allowOutside };
|
|
45
|
+
|
|
46
|
+
// ask mode: defer to the interactive handler, mapping its decision back.
|
|
47
|
+
const decision = requestPermission ? await requestPermission({ tool: toolName, input }) : 'allow';
|
|
48
|
+
if (decision === 'reject') return { allowed: false, allowOutside, reason: 'denied by user' };
|
|
49
|
+
if (decision === 'allow_always') { alwaysAllowed = true; return { allowed: true, allowOutside }; }
|
|
50
|
+
return { allowed: true, allowOutside };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { check, get alwaysAllowed() { return alwaysAllowed; } };
|
|
54
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apps/chat/engine/tools/primitives.mjs — zero-dep executors for the owned loop's
|
|
3
|
+
* agent tools (read, write, edit, glob, grep, shell).
|
|
4
|
+
*
|
|
5
|
+
* These are the side-effecting primitives the loop calls; they are pure Node so
|
|
6
|
+
* they are fully testable in a tmpdir with no model, network, or SDK. Path safety
|
|
7
|
+
* is enforced here: every path resolves inside the workspace root unless the caller
|
|
8
|
+
* passes `allowOutside` (set only by the danger-full-access sandbox). Search tools
|
|
9
|
+
* walk the tree with a hard result/size cap and skip the usual heavy/ignored dirs
|
|
10
|
+
* so a grep never hangs the loop. Shell reuses the bounded worker runner
|
|
11
|
+
* (lib/worker/run.mjs) for the timeout + path policy already proven there.
|
|
12
|
+
*
|
|
13
|
+
* Every function returns a structured, JSON-serializable result (never throws for
|
|
14
|
+
* an ordinary "not found"/"no match" outcome) so the result maps cleanly onto a
|
|
15
|
+
* tool_update event and back to the model.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { runJob } from '../../../../lib/worker/run.mjs';
|
|
22
|
+
|
|
23
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git', '.cx', 'dist', 'build', '.next', 'coverage', '.cache']);
|
|
24
|
+
const MAX_READ_BYTES = 256 * 1024;
|
|
25
|
+
const MAX_WALK_FILES = 20000;
|
|
26
|
+
|
|
27
|
+
function resolveInside(cwd, target, { allowOutside = false } = {}) {
|
|
28
|
+
const root = path.resolve(cwd);
|
|
29
|
+
const resolved = path.resolve(root, target || '.');
|
|
30
|
+
if (!allowOutside && resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
31
|
+
const err = new Error(`path '${target}' is outside the workspace; not permitted under the current sandbox`);
|
|
32
|
+
err.code = 'PATH_OUTSIDE_WORKSPACE';
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readFileTool({ cwd, path: target, maxBytes = MAX_READ_BYTES, allowOutside = false } = {}) {
|
|
39
|
+
const file = resolveInside(cwd, target, { allowOutside });
|
|
40
|
+
if (!fs.existsSync(file)) return { ok: false, error: `no such file: ${target}` };
|
|
41
|
+
const stat = fs.statSync(file);
|
|
42
|
+
if (stat.isDirectory()) return { ok: false, error: `${target} is a directory` };
|
|
43
|
+
const buf = fs.readFileSync(file);
|
|
44
|
+
const truncated = buf.length > maxBytes;
|
|
45
|
+
return { ok: true, path: target, bytes: stat.size, truncated, content: buf.slice(0, maxBytes).toString('utf8') };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writeFileTool({ cwd, path: target, content = '', allowOutside = false } = {}) {
|
|
49
|
+
const file = resolveInside(cwd, target, { allowOutside });
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
52
|
+
return { ok: true, path: target, bytes: Buffer.byteLength(content, 'utf8') };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function editFileTool({ cwd, path: target, oldString, newString = '', replaceAll = false, allowOutside = false } = {}) {
|
|
56
|
+
const file = resolveInside(cwd, target, { allowOutside });
|
|
57
|
+
if (!fs.existsSync(file)) return { ok: false, error: `no such file: ${target}` };
|
|
58
|
+
if (typeof oldString !== 'string' || oldString.length === 0) return { ok: false, error: 'oldString is required and must be non-empty' };
|
|
59
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
60
|
+
const occurrences = before.split(oldString).length - 1;
|
|
61
|
+
if (occurrences === 0) return { ok: false, error: 'oldString not found in file' };
|
|
62
|
+
if (occurrences > 1 && !replaceAll) return { ok: false, error: `oldString is not unique (${occurrences} matches); pass replaceAll or add more context` };
|
|
63
|
+
const after = replaceAll ? before.split(oldString).join(newString) : before.replace(oldString, newString);
|
|
64
|
+
fs.writeFileSync(file, after, 'utf8');
|
|
65
|
+
return { ok: true, path: target, replacements: replaceAll ? occurrences : 1 };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Bounded depth-first walk yielding workspace-relative file paths, skipping the
|
|
69
|
+
// ignored dirs. Caps the number of files visited so a pathological tree cannot
|
|
70
|
+
// stall the loop.
|
|
71
|
+
|
|
72
|
+
function* walkFiles(root) {
|
|
73
|
+
const stack = [root];
|
|
74
|
+
let visited = 0;
|
|
75
|
+
while (stack.length) {
|
|
76
|
+
const dir = stack.pop();
|
|
77
|
+
let entries;
|
|
78
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const full = path.join(dir, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
83
|
+
stack.push(full);
|
|
84
|
+
} else if (entry.isFile()) {
|
|
85
|
+
if (++visited > MAX_WALK_FILES) return;
|
|
86
|
+
yield full;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Translate a simple glob ("**/*.mjs", "src/*.ts", "lib/**") into a RegExp. Only
|
|
93
|
+
// the common operators are supported (no brace expansion), which covers what the
|
|
94
|
+
// loop needs without pulling a glob dependency.
|
|
95
|
+
|
|
96
|
+
function globToRegExp(pattern) {
|
|
97
|
+
let re = '';
|
|
98
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
99
|
+
const c = pattern[i];
|
|
100
|
+
if (c === '*') {
|
|
101
|
+
if (pattern[i + 1] === '*') { re += '.*'; i++; if (pattern[i + 1] === '/') i++; }
|
|
102
|
+
else re += '[^/]*';
|
|
103
|
+
} else if (c === '?') re += '[^/]';
|
|
104
|
+
else if ('.+^${}()|[]\\'.includes(c)) re += `\\${c}`;
|
|
105
|
+
else re += c;
|
|
106
|
+
}
|
|
107
|
+
return new RegExp(`^${re}$`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function globTool({ cwd, pattern, limit = 200, allowOutside = false } = {}) {
|
|
111
|
+
const root = resolveInside(cwd, '.', { allowOutside });
|
|
112
|
+
if (!pattern) return { ok: false, error: 'pattern is required' };
|
|
113
|
+
const rx = globToRegExp(pattern);
|
|
114
|
+
const matches = [];
|
|
115
|
+
for (const file of walkFiles(root)) {
|
|
116
|
+
const rel = path.relative(root, file);
|
|
117
|
+
if (rx.test(rel)) {
|
|
118
|
+
matches.push(rel);
|
|
119
|
+
if (matches.length >= limit) break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, pattern, matches, truncated: matches.length >= limit };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function grepTool({ cwd, pattern, glob = null, limit = 200, caseInsensitive = false, allowOutside = false } = {}) {
|
|
126
|
+
const root = resolveInside(cwd, '.', { allowOutside });
|
|
127
|
+
if (!pattern) return { ok: false, error: 'pattern is required' };
|
|
128
|
+
let rx;
|
|
129
|
+
try { rx = new RegExp(pattern, caseInsensitive ? 'i' : ''); } catch (err) { return { ok: false, error: `invalid pattern: ${err.message}` }; }
|
|
130
|
+
const globRx = glob ? globToRegExp(glob) : null;
|
|
131
|
+
const matches = [];
|
|
132
|
+
for (const file of walkFiles(root)) {
|
|
133
|
+
const rel = path.relative(root, file);
|
|
134
|
+
if (globRx && !globRx.test(rel)) continue;
|
|
135
|
+
let text;
|
|
136
|
+
try {
|
|
137
|
+
const stat = fs.statSync(file);
|
|
138
|
+
if (stat.size > MAX_READ_BYTES) continue;
|
|
139
|
+
text = fs.readFileSync(file, 'utf8');
|
|
140
|
+
} catch { continue; }
|
|
141
|
+
if (text.includes('\u0000')) continue;
|
|
142
|
+
const lines = text.split('\n');
|
|
143
|
+
for (let i = 0; i < lines.length; i++) {
|
|
144
|
+
if (rx.test(lines[i])) {
|
|
145
|
+
matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
|
|
146
|
+
if (matches.length >= limit) return { ok: true, pattern, matches, truncated: true };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, pattern, matches, truncated: false };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function shellTool({ cwd, command, timeoutSeconds = 60, allowOutside = false } = {}) {
|
|
154
|
+
if (!command || typeof command !== 'string') return { ok: false, error: 'command is required' };
|
|
155
|
+
const root = path.resolve(cwd);
|
|
156
|
+
const result = await runJob({
|
|
157
|
+
rootDir: root,
|
|
158
|
+
job: {
|
|
159
|
+
jobId: `chat-${randomUUID()}`,
|
|
160
|
+
command,
|
|
161
|
+
timeoutSeconds,
|
|
162
|
+
workspaceRef: root,
|
|
163
|
+
allowedPaths: allowOutside ? [] : [root],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
let stdout = '';
|
|
167
|
+
let stderr = '';
|
|
168
|
+
try { stdout = fs.readFileSync(result.stdoutPath, 'utf8'); } catch { /* no stdout */ }
|
|
169
|
+
try { stderr = fs.readFileSync(result.stderrPath, 'utf8'); } catch { /* no stderr */ }
|
|
170
|
+
return {
|
|
171
|
+
ok: result.status === 'passed',
|
|
172
|
+
status: result.status,
|
|
173
|
+
exitCode: result.exitCode,
|
|
174
|
+
durationMs: result.durationMs,
|
|
175
|
+
stdout: stdout.slice(0, MAX_READ_BYTES),
|
|
176
|
+
stderr: stderr.slice(0, MAX_READ_BYTES),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const __test__ = { resolveInside, globToRegExp };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apps/chat/engine/tools/registry.mjs — builds the owned loop's tool set as Vercel
|
|
3
|
+
* AI SDK tools, gated by the permission/sandbox policy.
|
|
4
|
+
*
|
|
5
|
+
* Each tool wraps a zero-dep primitive (primitives.mjs) behind the permission gate
|
|
6
|
+
* (permission.mjs): the gate runs first, and a denial returns a structured result
|
|
7
|
+
* the model can read rather than throwing, so the loop degrades gracefully instead
|
|
8
|
+
* of crashing on a blocked action. A single `construct_tool` bridges the loop to
|
|
9
|
+
* Construct's existing MCP tool surface via dispatchToolByName (mirroring the MCP
|
|
10
|
+
* `construct_call` meta-tool), so the loop reaches knowledge search, skills, and
|
|
11
|
+
* orchestration policy without re-declaring 60+ schemas here.
|
|
12
|
+
*
|
|
13
|
+
* `ai` and `zod` are imported lazily and only here, so the rest of the engine and
|
|
14
|
+
* all of its tests stay free of the optional dependencies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readFileTool, writeFileTool, editFileTool, globTool, grepTool, shellTool,
|
|
19
|
+
} from './primitives.mjs';
|
|
20
|
+
import { createPermissionGate } from './permission.mjs';
|
|
21
|
+
|
|
22
|
+
function denied(reason) {
|
|
23
|
+
return { ok: false, denied: true, error: reason };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function buildAgentTools({ env = process.env, cwd = process.cwd(), handlers = {}, only = null } = {}) {
|
|
27
|
+
const { tool } = await import('ai');
|
|
28
|
+
const { z } = await import('zod');
|
|
29
|
+
|
|
30
|
+
const gate = createPermissionGate({
|
|
31
|
+
getSandbox: handlers.getSandbox || (() => null),
|
|
32
|
+
getPermissionMode: handlers.getPermissionMode || (() => 'allow_once'),
|
|
33
|
+
requestPermission: handlers.requestPermission || null,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const defs = {
|
|
37
|
+
read: tool({
|
|
38
|
+
description: 'Read a UTF-8 text file from the workspace.',
|
|
39
|
+
inputSchema: z.object({ path: z.string().describe('workspace-relative file path') }),
|
|
40
|
+
execute: async ({ path: p }) => {
|
|
41
|
+
const verdict = await gate.check('read', { path: p });
|
|
42
|
+
if (!verdict.allowed) return denied(verdict.reason);
|
|
43
|
+
return readFileTool({ cwd, path: p, allowOutside: verdict.allowOutside });
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
glob: tool({
|
|
47
|
+
description: 'Find files by glob pattern (supports * ** ?).',
|
|
48
|
+
inputSchema: z.object({ pattern: z.string(), limit: z.number().int().positive().max(1000).optional() }),
|
|
49
|
+
execute: async ({ pattern, limit }) => {
|
|
50
|
+
const verdict = await gate.check('glob', { pattern });
|
|
51
|
+
if (!verdict.allowed) return denied(verdict.reason);
|
|
52
|
+
return globTool({ cwd, pattern, limit, allowOutside: verdict.allowOutside });
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
grep: tool({
|
|
56
|
+
description: 'Search file contents by regular expression, optionally filtered by a glob.',
|
|
57
|
+
inputSchema: z.object({
|
|
58
|
+
pattern: z.string(),
|
|
59
|
+
glob: z.string().optional(),
|
|
60
|
+
caseInsensitive: z.boolean().optional(),
|
|
61
|
+
limit: z.number().int().positive().max(1000).optional(),
|
|
62
|
+
}),
|
|
63
|
+
execute: async ({ pattern, glob, caseInsensitive, limit }) => {
|
|
64
|
+
const verdict = await gate.check('grep', { pattern });
|
|
65
|
+
if (!verdict.allowed) return denied(verdict.reason);
|
|
66
|
+
return grepTool({ cwd, pattern, glob, caseInsensitive, limit, allowOutside: verdict.allowOutside });
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
write: tool({
|
|
70
|
+
description: 'Create or overwrite a workspace file with the given content.',
|
|
71
|
+
inputSchema: z.object({ path: z.string(), content: z.string() }),
|
|
72
|
+
execute: async ({ path: p, content }) => {
|
|
73
|
+
const verdict = await gate.check('write', { path: p });
|
|
74
|
+
if (!verdict.allowed) return denied(verdict.reason);
|
|
75
|
+
return writeFileTool({ cwd, path: p, content, allowOutside: verdict.allowOutside });
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
edit: tool({
|
|
79
|
+
description: 'Replace an exact string in a workspace file. oldString must be unique unless replaceAll is set.',
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
path: z.string(),
|
|
82
|
+
oldString: z.string(),
|
|
83
|
+
newString: z.string(),
|
|
84
|
+
replaceAll: z.boolean().optional(),
|
|
85
|
+
}),
|
|
86
|
+
execute: async ({ path: p, oldString, newString, replaceAll }) => {
|
|
87
|
+
const verdict = await gate.check('edit', { path: p });
|
|
88
|
+
if (!verdict.allowed) return denied(verdict.reason);
|
|
89
|
+
return editFileTool({ cwd, path: p, oldString, newString, replaceAll, allowOutside: verdict.allowOutside });
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
shell: tool({
|
|
93
|
+
description: 'Run a bounded shell command in the workspace with a timeout.',
|
|
94
|
+
inputSchema: z.object({ command: z.string(), timeoutSeconds: z.number().int().positive().max(600).optional() }),
|
|
95
|
+
execute: async ({ command, timeoutSeconds }) => {
|
|
96
|
+
const verdict = await gate.check('shell', { command });
|
|
97
|
+
if (!verdict.allowed) return denied(verdict.reason);
|
|
98
|
+
return shellTool({ cwd, command, timeoutSeconds, allowOutside: verdict.allowOutside });
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
construct_tool: tool({
|
|
102
|
+
description: 'Call a Construct MCP tool by name (e.g. knowledge_search, search_skills, orchestration_policy). Returns the tool result.',
|
|
103
|
+
inputSchema: z.object({ name: z.string(), args: z.record(z.string(), z.any()).optional() }),
|
|
104
|
+
execute: async ({ name, args }) => {
|
|
105
|
+
try {
|
|
106
|
+
const { dispatchToolByName } = await import('../../../../lib/mcp/server.mjs');
|
|
107
|
+
const result = await dispatchToolByName(name, args || {});
|
|
108
|
+
return { ok: true, name, result };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { ok: false, name, error: err?.message || String(err) };
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (!Array.isArray(only) || only.length === 0) return defs;
|
|
117
|
+
const filtered = {};
|
|
118
|
+
for (const name of only) if (defs[name]) filtered[name] = defs[name];
|
|
119
|
+
return filtered;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const AGENT_TOOL_NAMES = ['read', 'glob', 'grep', 'write', 'edit', 'shell', 'construct_tool'];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apps/chat/engine/turn-controls.mjs — resolve one turn's execution controls from
|
|
3
|
+
* the compiled execution policy (construct-rv2x).
|
|
4
|
+
*
|
|
5
|
+
* The owned-loop engine (ai-sdk-agent.mjs) calls this once per turn to turn a
|
|
6
|
+
* capability profile plus the turn overlay into the concrete knobs streamText
|
|
7
|
+
* needs: the step cap, the output-token cap, caching eligibility, and the tool-
|
|
8
|
+
* group / schema budget, plus the continuation/compaction budget the session uses
|
|
9
|
+
* to decide when to compact context (construct-6zga.1.9). The capability profile
|
|
10
|
+
* is resolved from the active model id, so a mid-session model switch re-derives
|
|
11
|
+
* the envelope.
|
|
12
|
+
*
|
|
13
|
+
* Behavior preservation is the contract: this never throws and never returns a
|
|
14
|
+
* tighter envelope than today for a hosted-direct model. hosted-direct keeps the
|
|
15
|
+
* legacy step cap, the full tool set, and unbounded output; every other class
|
|
16
|
+
* enforces its compiled budget. An explicit CX_CHAT_MAX_STEPS operator override
|
|
17
|
+
* wins over the policy's step cap. Any failure in policy resolution falls back to
|
|
18
|
+
* the legacy envelope (16 steps, full tool set, no cap, no caching) so a policy
|
|
19
|
+
* bug can never break a chat turn.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { resolveExecutionCapabilityProfile } from '../../../lib/models/execution-capability-profile.mjs';
|
|
23
|
+
import { compilePolicyFromOverlay } from '../../../lib/models/execution-policy.mjs';
|
|
24
|
+
import { continuationBudgetFromPolicy } from '../../../lib/chat/context-continuation.mjs';
|
|
25
|
+
|
|
26
|
+
const LEGACY_STEP_CAP = 16;
|
|
27
|
+
|
|
28
|
+
const LEGACY_CONTROLS = Object.freeze({
|
|
29
|
+
policy: null,
|
|
30
|
+
iterations: LEGACY_STEP_CAP,
|
|
31
|
+
outputCap: null,
|
|
32
|
+
cacheEligible: false,
|
|
33
|
+
allowedToolGroups: null,
|
|
34
|
+
maxToolSchemas: Infinity,
|
|
35
|
+
degraded: false,
|
|
36
|
+
continuation: Object.freeze({ triggerTokens: null, triggerRatio: null }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function legacyControls() {
|
|
40
|
+
return { ...LEGACY_CONTROLS };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveTurnControls({ model = null, turnOverlay = null, env = {} } = {}) {
|
|
44
|
+
try {
|
|
45
|
+
const profile = resolveExecutionCapabilityProfile({ model });
|
|
46
|
+
const toolFailure = turnOverlay?.toolFailure === true;
|
|
47
|
+
const policy = compilePolicyFromOverlay({ profile, overlay: turnOverlay, toolFailure });
|
|
48
|
+
|
|
49
|
+
const envSteps = Number(env?.CX_CHAT_MAX_STEPS);
|
|
50
|
+
const iterations = envSteps > 0 ? Math.floor(envSteps) : policy.tools.maxToolIterations;
|
|
51
|
+
|
|
52
|
+
// hosted-direct is the mandated default path: keep today's unbounded output
|
|
53
|
+
// until live-probe evidence supersedes the compatibility_fallback tier. Every
|
|
54
|
+
// other class enforces its compiled output budget.
|
|
55
|
+
const outputCap = policy.source.capabilityClass === 'hosted-direct' ? null : policy.output.outputTokenBudget;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
policy,
|
|
59
|
+
iterations,
|
|
60
|
+
outputCap,
|
|
61
|
+
cacheEligible: policy.caching.eligible === true,
|
|
62
|
+
allowedToolGroups: policy.tools.allowedToolGroups,
|
|
63
|
+
maxToolSchemas: policy.tools.maxToolSchemas,
|
|
64
|
+
degraded: policy.degradedMode === true,
|
|
65
|
+
continuation: continuationBudgetFromPolicy(policy),
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
return legacyControls();
|
|
69
|
+
}
|
|
70
|
+
}
|
package/bin/construct
CHANGED
|
@@ -653,6 +653,21 @@ async function cmdDoctor() {
|
|
|
653
653
|
);
|
|
654
654
|
}
|
|
655
655
|
|
|
656
|
+
// Mirror model tier overrides stranded in the pre-XDG legacy config forward
|
|
657
|
+
// before resolving tiers, so an install upgraded across the XDG move reports
|
|
658
|
+
// configured without a manual copy. Applied to process.env too so the check
|
|
659
|
+
// below sees the just-migrated values in the same run.
|
|
660
|
+
try {
|
|
661
|
+
const { migrateLegacyModelConfig } = await import('../lib/config/legacy-config-migration.mjs');
|
|
662
|
+
const migration = migrateLegacyModelConfig({ homeDir: HOME });
|
|
663
|
+
if (migration.performed) {
|
|
664
|
+
Object.assign(process.env, migration.migrated);
|
|
665
|
+
println(` fix: migrated ${Object.keys(migration.migrated).join(', ')} from ${migration.legacyPath} → ${migration.xdgPath}`);
|
|
666
|
+
}
|
|
667
|
+
} catch (err) {
|
|
668
|
+
add(`Models — legacy config migration failed: ${err.message}`, false, true);
|
|
669
|
+
}
|
|
670
|
+
|
|
656
671
|
// Tier model selection. Construct ships with no default — at least
|
|
657
672
|
// one tier must be configured (registry.json primary OR CX_MODEL_*
|
|
658
673
|
// env override) before any LLM-backed workflow can run.
|
|
@@ -3773,7 +3788,15 @@ async function cmdOrchestrate(args) {
|
|
|
3773
3788
|
}
|
|
3774
3789
|
|
|
3775
3790
|
async function cmdModels(args) {
|
|
3776
|
-
|
|
3791
|
+
const envPath = getUserEnvPath(HOME);
|
|
3792
|
+
const sub = args[0] && !args[0].startsWith('-') ? args[0] : null;
|
|
3793
|
+
const KNOWN_SUBCOMMANDS = new Set(['list', 'set', 'free', 'reset', 'resolve']);
|
|
3794
|
+
if (sub && !KNOWN_SUBCOMMANDS.has(sub)) {
|
|
3795
|
+
errorln(`Unknown models subcommand: ${sub}. Run \`construct models --help\` for the supported set.`);
|
|
3796
|
+
process.exit(1);
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
if (sub === 'resolve') {
|
|
3777
3800
|
const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
|
|
3778
3801
|
const { resolveEmbeddedModel } = await import('../lib/embedded-contract/model-resolve.mjs');
|
|
3779
3802
|
const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
|
|
@@ -3790,21 +3813,37 @@ async function cmdModels(args) {
|
|
|
3790
3813
|
println(JSON.stringify(envelope, null, 2));
|
|
3791
3814
|
return;
|
|
3792
3815
|
}
|
|
3816
|
+
// Legacy flag forms stay as compatibility aliases for the documented
|
|
3817
|
+
// subcommands. Each emits a one-line deprecation notice on stderr so stdout
|
|
3818
|
+
// stays clean while operators migrate to `models set|reset|free`.
|
|
3793
3819
|
const tier = args.find((arg) => arg.startsWith('--tier='))?.split('=')[1] ?? '';
|
|
3794
|
-
const
|
|
3795
|
-
const
|
|
3796
|
-
|
|
3820
|
+
const modelFlag = args.find((arg) => arg.startsWith('--model='))?.split('=')[1];
|
|
3821
|
+
const legacySetFlag = args.find((arg) => arg.startsWith('--set='))?.split('=')[1];
|
|
3822
|
+
const setModel = modelFlag ?? legacySetFlag ?? '';
|
|
3823
|
+
|
|
3824
|
+
if (sub === 'reset' || args.includes('--reset')) {
|
|
3825
|
+
if (sub !== 'reset') errorln('Note: `construct models --reset` is a deprecated alias for `construct models reset`.');
|
|
3797
3826
|
resetEnv(envPath);
|
|
3798
|
-
println(
|
|
3827
|
+
println(`Removed CX_MODEL_* overrides from ${envPath}.`);
|
|
3799
3828
|
await cmdSync([]);
|
|
3800
3829
|
return;
|
|
3801
3830
|
}
|
|
3802
|
-
if (tier && setModel) {
|
|
3831
|
+
if (sub === 'set' || (sub === null && tier && setModel)) {
|
|
3832
|
+
if (!tier || !setModel) {
|
|
3833
|
+
errorln('Usage: construct models set --tier=<reasoning|standard|fast> --model=<provider/model-id>');
|
|
3834
|
+
process.exit(1);
|
|
3835
|
+
}
|
|
3836
|
+
if (modelFlag === undefined && legacySetFlag !== undefined) {
|
|
3837
|
+
errorln('Note: `--set=<model>` is a deprecated alias for `--model=<model>`.');
|
|
3838
|
+
}
|
|
3839
|
+
if (sub === null) {
|
|
3840
|
+
errorln('Note: `construct models --tier=… --set=…` is a deprecated alias for `construct models set --tier=… --model=…`.');
|
|
3841
|
+
}
|
|
3803
3842
|
const registry = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'specialists', 'registry.json'), 'utf8'));
|
|
3804
3843
|
const preferFree = args.includes('--prefer-free');
|
|
3805
3844
|
const preferFreeSameFamily = args.includes('--prefer-free-same-family');
|
|
3806
3845
|
const inferred = setModelWithTierInference(envPath, tier, setModel, registry.models ?? {}, { preferFree, preferFreeSameFamily });
|
|
3807
|
-
println(`Set ${tier} -> ${setModel} in
|
|
3846
|
+
println(`Set ${tier} -> ${setModel} in ${envPath}`);
|
|
3808
3847
|
if (preferFreeSameFamily) println('Prefer-free-same-family mode: enabled');
|
|
3809
3848
|
if (preferFree) println('Prefer-free mode: enabled');
|
|
3810
3849
|
println(`Resolved tier set:`);
|
|
@@ -3814,7 +3853,8 @@ async function cmdModels(args) {
|
|
|
3814
3853
|
await cmdSync([]);
|
|
3815
3854
|
return;
|
|
3816
3855
|
}
|
|
3817
|
-
if (args.includes('--poll')) {
|
|
3856
|
+
if (sub === 'free' || args.includes('--poll')) {
|
|
3857
|
+
if (sub !== 'free') errorln('Note: `construct models --poll` is a deprecated alias for `construct models free`.');
|
|
3818
3858
|
const apiKey = process.env.OPENROUTER_API_KEY || readOpenRouterApiKeyFromOpenCodeConfig();
|
|
3819
3859
|
if (!apiKey) {
|
|
3820
3860
|
errorln('Error: no OpenRouter API key found in OPENROUTER_API_KEY or OpenCode config.');
|
|
@@ -3841,7 +3881,7 @@ async function cmdModels(args) {
|
|
|
3841
3881
|
if (id) selections[currentTier] = id;
|
|
3842
3882
|
}
|
|
3843
3883
|
applyToEnv(envPath, selections);
|
|
3844
|
-
println(
|
|
3884
|
+
println(`Written to ${envPath}. Running construct sync...`);
|
|
3845
3885
|
await cmdSync([]);
|
|
3846
3886
|
return;
|
|
3847
3887
|
}
|
|
@@ -3878,12 +3918,12 @@ async function cmdModels(args) {
|
|
|
3878
3918
|
for (const [tier, model] of Object.entries(selections)) {
|
|
3879
3919
|
println(` ${tier.padEnd(11)} ${model}`);
|
|
3880
3920
|
}
|
|
3881
|
-
println(
|
|
3921
|
+
println(`Written to ${envPath}. Running construct sync...`);
|
|
3882
3922
|
setCheapestProviderPreference(envPath, true);
|
|
3883
3923
|
await cmdSync([]);
|
|
3884
3924
|
return;
|
|
3885
3925
|
}
|
|
3886
|
-
if (args.includes('--list') ||
|
|
3926
|
+
if (args.includes('--list') || sub === 'list') {
|
|
3887
3927
|
const { getProviderModelCatalog } = await import('../lib/model-router.mjs');
|
|
3888
3928
|
const { listChatModels } = await import('../apps/chat/engine/models.mjs');
|
|
3889
3929
|
const catalog = getProviderModelCatalog({ env: process.env, cwd: process.cwd() });
|
|
@@ -3913,7 +3953,7 @@ async function cmdModels(args) {
|
|
|
3913
3953
|
println('No tier has a model selected. Construct ships with no default.');
|
|
3914
3954
|
println('Pick one of:');
|
|
3915
3955
|
println(' • Dashboard → Models page (single source of truth)');
|
|
3916
|
-
println(' • construct models
|
|
3956
|
+
println(' • construct models set --tier=<reasoning|standard|fast> --model=<provider/model-id>');
|
|
3917
3957
|
println(' • construct models --apply Poll OpenRouter free catalog and seed all tiers');
|
|
3918
3958
|
}
|
|
3919
3959
|
}
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -429,14 +429,12 @@ export const CLI_COMMANDS = [
|
|
|
429
429
|
category: 'Models & Integrations',
|
|
430
430
|
core: false,
|
|
431
431
|
description: 'Show or update model tier assignments',
|
|
432
|
-
usage: 'construct models <list|set|free|reset|
|
|
432
|
+
usage: 'construct models <list|set|free|reset|resolve>',
|
|
433
433
|
subcommands: [
|
|
434
434
|
{ name: 'list', desc: 'Show current tier assignments' },
|
|
435
435
|
{ name: 'set --tier=<reasoning|standard|fast> --model=<model>', desc: 'Set a model for a tier' },
|
|
436
436
|
{ name: 'free', desc: 'List available free models' },
|
|
437
437
|
{ name: 'reset', desc: 'Reset all tier assignments' },
|
|
438
|
-
{ name: 'usage', desc: 'Show token usage per tier' },
|
|
439
|
-
{ name: 'cost', desc: 'Show cost breakdown' },
|
|
440
438
|
{ name: 'resolve --json', desc: 'Resolve the model for an embedded workflow given host context' },
|
|
441
439
|
],
|
|
442
440
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/config/legacy-config-migration.mjs — one-time forward migration of model
|
|
3
|
+
* tier overrides from the pre-XDG legacy config into the active XDG config.
|
|
4
|
+
*
|
|
5
|
+
* The config move to $XDG_CONFIG_HOME/construct (see lib/config/xdg.mjs) is a
|
|
6
|
+
* clean break with no legacy read, so any install that set CX_MODEL_* before
|
|
7
|
+
* the move keeps those values stranded in ~/.construct/config.env. Resolution
|
|
8
|
+
* reads only the XDG path, so doctor then reports "no tier configured" until
|
|
9
|
+
* the user hand-copies the keys. This mirrors the stranded tier overrides
|
|
10
|
+
* forward, and never overwrites a value the XDG config already defines.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { parseEnvFile, writeEnvValues } from '../env-config.mjs';
|
|
18
|
+
import { configDir } from './xdg.mjs';
|
|
19
|
+
|
|
20
|
+
// Only model tier overrides migrate. API keys and other secrets are
|
|
21
|
+
// intentionally out of scope — they are re-established through the credential
|
|
22
|
+
// bootstrap, not copied between paths.
|
|
23
|
+
|
|
24
|
+
const MIGRATABLE_KEY = /^(?:CX|CONSTRUCT)_MODEL_[A-Z0-9_]+$/;
|
|
25
|
+
|
|
26
|
+
export function legacyConfigPath(homeDir = os.homedir()) {
|
|
27
|
+
return path.join(homeDir, '.construct', 'config.env');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Migrate stranded CX_MODEL_ and CONSTRUCT_MODEL_ tier overrides from the
|
|
32
|
+
* legacy config into the XDG config. Returns the keys that were written so
|
|
33
|
+
* callers can surface a one-time message and refresh their in-process env.
|
|
34
|
+
*/
|
|
35
|
+
export function migrateLegacyModelConfig({ homeDir = os.homedir(), env = process.env } = {}) {
|
|
36
|
+
const legacyPath = legacyConfigPath(homeDir);
|
|
37
|
+
const xdgPath = path.join(configDir(homeDir, env), 'config.env');
|
|
38
|
+
const result = { performed: false, migrated: {}, legacyPath, xdgPath };
|
|
39
|
+
|
|
40
|
+
if (legacyPath === xdgPath || !fs.existsSync(legacyPath)) return result;
|
|
41
|
+
|
|
42
|
+
const legacyEnv = parseEnvFile(legacyPath);
|
|
43
|
+
const xdgEnv = parseEnvFile(xdgPath);
|
|
44
|
+
|
|
45
|
+
const migrated = {};
|
|
46
|
+
for (const [key, value] of Object.entries(legacyEnv)) {
|
|
47
|
+
if (!MIGRATABLE_KEY.test(key)) continue;
|
|
48
|
+
if (value === '' || value === undefined) continue;
|
|
49
|
+
if (xdgEnv[key] !== undefined && xdgEnv[key] !== '') continue;
|
|
50
|
+
migrated[key] = value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Object.keys(migrated).length === 0) return result;
|
|
54
|
+
|
|
55
|
+
writeEnvValues(xdgPath, migrated);
|
|
56
|
+
result.performed = true;
|
|
57
|
+
result.migrated = migrated;
|
|
58
|
+
return result;
|
|
59
|
+
}
|
package/lib/runtime-env.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/runtime-env.mjs — Construct runtime environment with credential bootstrap.
|
|
3
3
|
*
|
|
4
|
-
* prepareConstructEnv merges ~/.construct/config.env into the process environment.
|
|
4
|
+
* prepareConstructEnv merges ~/.config/construct/config.env into the process environment.
|
|
5
5
|
* Credential auto-link from 1Password runs only when autoLink:true (setup-credentials).
|
|
6
6
|
*/
|
|
7
7
|
|