@lazyingart/agintiflow 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,9 +93,17 @@ export function getSandboxLogs(limit = 30) {
93
93
  return sandboxLogs.slice(-limit);
94
94
  }
95
95
 
96
+ function sandboxPackageDir(config) {
97
+ return path.resolve(config.packageDir || config.baseDir || process.cwd());
98
+ }
99
+
100
+ function sandboxDockerfilePath(config) {
101
+ return path.join(sandboxPackageDir(config), "docker", "sandbox.Dockerfile");
102
+ }
103
+
96
104
  export async function getDockerSandboxStatus(config) {
97
105
  const image = config.dockerSandboxImage;
98
- const dockerfilePath = path.join(config.baseDir, "docker", "sandbox.Dockerfile");
106
+ const dockerfilePath = sandboxDockerfilePath(config);
99
107
  const workspace = config.commandCwd;
100
108
  const [dockerReady, dockerfileExists, workspaceExists, workspaceReadable, workspaceWritable] = await Promise.all([
101
109
  dockerAvailable(),
@@ -125,14 +133,15 @@ export async function getDockerSandboxStatus(config) {
125
133
 
126
134
  async function buildDockerSandboxImage(config, observers) {
127
135
  const image = config.dockerSandboxImage;
128
- const dockerfilePath = path.join(config.baseDir, "docker", "sandbox.Dockerfile");
136
+ const packageDir = sandboxPackageDir(config);
137
+ const dockerfilePath = sandboxDockerfilePath(config);
129
138
  observers?.log?.("docker.building", {
130
139
  image,
131
140
  dockerfilePath,
132
141
  });
133
142
  recordSandboxLog("docker.building", { image, dockerfilePath });
134
143
 
135
- await execDocker(["build", "-t", image, "-f", dockerfilePath, config.baseDir], {
144
+ await execDocker(["build", "-t", image, "-f", dockerfilePath, packageDir], {
136
145
  timeout: 10 * 60 * 1000,
137
146
  maxBuffer: 1024 * 1024,
138
147
  });
@@ -146,7 +155,7 @@ export async function ensureDockerSandboxReady(config, observers, options = {})
146
155
  const image = config.dockerSandboxImage;
147
156
  if (!config.useDockerSandbox || (READY_IMAGES.has(image) && !options.forceBuild)) return;
148
157
 
149
- const dockerfilePath = path.join(config.baseDir, "docker", "sandbox.Dockerfile");
158
+ const dockerfilePath = sandboxDockerfilePath(config);
150
159
  await fs.access(dockerfilePath).catch(() => {
151
160
  throw new Error(`Docker sandbox file is missing: ${dockerfilePath}`);
152
161
  });
@@ -163,10 +172,11 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
163
172
  const sandboxMode = normalizeSandboxMode(config.sandboxMode);
164
173
  const mountMode = sandboxMode === "docker-workspace" ? "rw" : "ro";
165
174
  const networkMode = policy.needsNetwork ? "bridge" : "none";
175
+ const toolchain = policy.category === "toolchain";
166
176
  const readOnlyArgs =
167
177
  mountMode === "ro"
168
178
  ? ["--read-only", "--tmpfs", "/tmp:rw,nosuid,nodev,size=128m"]
169
- : ["--tmpfs", "/tmp:rw,nosuid,nodev,size=256m"];
179
+ : ["--tmpfs", `/tmp:rw,nosuid,nodev,size=${toolchain ? "512m" : "256m"}`];
170
180
 
171
181
  return [
172
182
  "run",
@@ -180,9 +190,9 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
180
190
  "--pids-limit",
181
191
  "256",
182
192
  "--memory",
183
- "768m",
193
+ toolchain ? "2g" : "768m",
184
194
  "--cpus",
185
- "1.5",
195
+ toolchain ? "2" : "1.5",
186
196
  ...readOnlyArgs,
187
197
  ...userArgs,
188
198
  "-e",
@@ -204,7 +214,7 @@ function dockerRunArgs(command, config, policy = evaluateCommandPolicy(command,
204
214
 
205
215
  export async function runDockerSandboxCommand(command, config, policy = evaluateCommandPolicy(command, config)) {
206
216
  const result = await execDocker(dockerRunArgs(command, config, policy), {
207
- timeout: policy.needsNetwork ? 120000 : 15000,
217
+ timeout: policy.needsNetwork ? 120000 : policy.category === "toolchain" ? 90000 : 15000,
208
218
  maxBuffer: 300 * 1024,
209
219
  });
210
220
 
@@ -238,6 +248,23 @@ export async function runDockerPreflight(config, options = {}) {
238
248
  const manifests = await detectWorkspaceManifests(config.commandCwd);
239
249
  let checks = [];
240
250
 
251
+ if (!config.useDockerSandbox || normalizeSandboxMode(config.sandboxMode) === "host") {
252
+ const result = {
253
+ ok: Boolean(statusBefore.workspaceExists && statusBefore.workspaceReadable),
254
+ status: statusBefore,
255
+ manifests,
256
+ checks,
257
+ logs: getSandboxLogs(),
258
+ };
259
+ recordSandboxLog("sandbox.preflight.completed", {
260
+ ok: result.ok,
261
+ mode: "host",
262
+ manifests,
263
+ checks: [],
264
+ });
265
+ return result;
266
+ }
267
+
241
268
  if (buildImage && statusBefore.dockerAvailable && statusBefore.dockerfileExists && !statusBefore.imageReady) {
242
269
  await ensureDockerSandboxReady(config, {
243
270
  log: (message, data) => recordSandboxLog(message, data),
@@ -253,6 +280,9 @@ export async function runDockerPreflight(config, options = {}) {
253
280
  "npm -v",
254
281
  "python3 --version",
255
282
  "python3 -m pip --version",
283
+ "python3 -c \"import matplotlib, numpy; print(matplotlib.__version__, numpy.__version__)\"",
284
+ "latexmk -version",
285
+ "pdflatex --version",
256
286
  "git --version",
257
287
  "rg --version",
258
288
  ]) {
package/src/guardrails.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { evaluateCommandPolicy } from "./command-policy.js";
2
+ import { checkWorkspaceToolUse, WORKSPACE_TOOL_NAMES } from "./workspace-tools.js";
3
+ import { normalizeWrapperName } from "./tool-wrappers.js";
2
4
 
3
5
  const DESTRUCTIVE_KEYWORDS = [
4
6
  "delete",
@@ -15,6 +17,7 @@ const DESTRUCTIVE_KEYWORDS = [
15
17
  ];
16
18
 
17
19
  const KNOWN_WRAPPERS = new Set(["codex", "claude", "gemini", "copilot", "qwen"]);
20
+ const MAX_CANVAS_CONTENT_BYTES = 120_000;
18
21
  const DESTRUCTIVE_PROMPT_HINTS = [
19
22
  "delete",
20
23
  "remove files",
@@ -44,6 +47,10 @@ export function isDomainAllowed(urlString, allowedDomains) {
44
47
  }
45
48
 
46
49
  export function checkToolUse({ toolName, args, snapshot, config }) {
50
+ if (WORKSPACE_TOOL_NAMES.includes(toolName)) {
51
+ return checkWorkspaceToolUse(toolName, args, config);
52
+ }
53
+
47
54
  if (toolName === "open_url") {
48
55
  if (!/^https?:\/\//.test(String(args.url || ""))) {
49
56
  return { allowed: false, reason: "Only http and https URLs are allowed." };
@@ -102,6 +109,11 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
102
109
  return { allowed: false, reason: `Unknown agent wrapper: ${wrapper}` };
103
110
  }
104
111
 
112
+ const preferredWrapper = normalizeWrapperName(config.preferredWrapper);
113
+ if (wrapper !== preferredWrapper) {
114
+ return { allowed: false, reason: `Only the selected wrapper is enabled for this run: ${preferredWrapper}` };
115
+ }
116
+
105
117
  const prompt = String(args.prompt || "").trim();
106
118
  if (prompt.length < 8) {
107
119
  return { allowed: false, reason: "Agent wrapper prompt is too short." };
@@ -118,5 +130,24 @@ export function checkToolUse({ toolName, args, snapshot, config }) {
118
130
  return { allowed: true };
119
131
  }
120
132
 
133
+ if (toolName === "send_to_canvas") {
134
+ const title = String(args.title || "").trim();
135
+ if (!title) return { allowed: false, reason: "Canvas title is required." };
136
+ const content = typeof args.content === "string" ? args.content : "";
137
+ if (Buffer.byteLength(content, "utf8") > MAX_CANVAS_CONTENT_BYTES) {
138
+ return {
139
+ allowed: false,
140
+ reason: "Canvas content is too large. Write it to a workspace file and send the path instead.",
141
+ };
142
+ }
143
+
144
+ const canvasPath = String(args.path || "").trim();
145
+ if (canvasPath) {
146
+ return checkWorkspaceToolUse("read_file", { path: canvasPath }, config);
147
+ }
148
+
149
+ return { allowed: true };
150
+ }
151
+
121
152
  return { allowed: true };
122
153
  }
@@ -1,14 +1,124 @@
1
1
  import OpenAI from "openai";
2
- import { WRAPPER_NAMES, wrapperStatusText } from "./tool-wrappers.js";
2
+ import { normalizeWrapperName, wrapperStatusText } from "./tool-wrappers.js";
3
3
 
4
4
  export function createClient(config) {
5
+ if (config.provider === "mock") {
6
+ return {
7
+ mock: true,
8
+ provider: "mock",
9
+ };
10
+ }
11
+
5
12
  return new OpenAI({
6
13
  apiKey: config.apiKey,
7
14
  baseURL: config.baseURL,
8
15
  });
9
16
  }
10
17
 
18
+ function mockToolCall(name, args = {}) {
19
+ return {
20
+ id: `mock-${name}-${Date.now()}`,
21
+ type: "function",
22
+ function: {
23
+ name,
24
+ arguments: JSON.stringify(args),
25
+ },
26
+ };
27
+ }
28
+
29
+ function latestToolPayload(messages) {
30
+ const toolMessage = [...messages].reverse().find((message) => message.role === "tool" && message.content);
31
+ if (!toolMessage) return null;
32
+
33
+ try {
34
+ return JSON.parse(toolMessage.content);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function prepareMessages(config, messages) {
41
+ if (config.provider === "deepseek") return messages;
42
+ return messages.map((message) => {
43
+ const prepared = { ...message };
44
+ delete prepared.reasoning_content;
45
+ delete prepared.reasoningContent;
46
+ return prepared;
47
+ });
48
+ }
49
+
50
+ function mockCommandForGoal(goal = "") {
51
+ const text = String(goal).toLowerCase();
52
+ if (/\blist\b|folder contents|directory contents|files?/.test(text)) return "ls -la";
53
+ return "pwd";
54
+ }
55
+
56
+ function mockPathForGoal(goal = "") {
57
+ const text = String(goal);
58
+ const explicit = text.match(/(?:file|path):\s*`?([A-Za-z0-9_./-]+)`?/i)?.[1];
59
+ if (explicit) return explicit;
60
+ if (/\.env/i.test(text)) return ".env";
61
+ if (/outside|escape/i.test(text)) return "../outside-workspace.txt";
62
+ if (/patch/i.test(text)) return "patch-target.txt";
63
+ return "mock-output.txt";
64
+ }
65
+
66
+ function mockWorkspaceToolForGoal(goal = "") {
67
+ const text = String(goal).toLowerCase();
68
+ const targetPath = mockPathForGoal(goal);
69
+ if (/patch|replace|edit/.test(text)) {
70
+ return mockToolCall("apply_patch", {
71
+ path: targetPath,
72
+ search: "old",
73
+ replace: "new",
74
+ expectedReplacements: 1,
75
+ });
76
+ }
77
+ if (/write|create|file|coding/.test(text)) {
78
+ return mockToolCall("write_file", {
79
+ path: targetPath,
80
+ mode: "create",
81
+ content: `Created by AgInTiFlow mock mode.\nGoal: ${String(goal).slice(0, 160)}\n`,
82
+ });
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function mockCanvasToolForGoal(goal = "") {
88
+ const text = String(goal).toLowerCase();
89
+ if (!/canvas|artifact|image|figure|visual|preview|render/.test(text)) return null;
90
+ return mockToolCall("send_to_canvas", {
91
+ title: "Mock canvas note",
92
+ kind: "markdown",
93
+ content: `# Mock canvas artifact\n\nAgInTiFlow can send selected text, diffs, snapshots, and files into the frontend canvas.\n\nGoal: ${String(goal).slice(0, 160)}`,
94
+ note: "Mock mode exercised the backend-to-frontend artifact tunnel.",
95
+ selected: true,
96
+ });
97
+ }
98
+
99
+ function mockChatResponse(content, toolCalls = []) {
100
+ return {
101
+ choices: [
102
+ {
103
+ message: {
104
+ role: "assistant",
105
+ content,
106
+ tool_calls: toolCalls,
107
+ },
108
+ },
109
+ ],
110
+ };
111
+ }
112
+
11
113
  export async function createPlan(client, config, state) {
114
+ if (client.mock) {
115
+ return [
116
+ "1. Inspect the request and prefer the local shell when available.",
117
+ "2. Use one safe allowlisted command if it answers the task.",
118
+ "3. Return a concise mock-mode result without using external model credentials.",
119
+ ].join("\n");
120
+ }
121
+
12
122
  const response = await client.chat.completions.create({
13
123
  model: config.model,
14
124
  temperature: 0,
@@ -25,9 +135,17 @@ export async function createPlan(client, config, state) {
25
135
  state.startUrl ? `Suggested start URL: ${state.startUrl}` : "",
26
136
  config.allowedDomains.length > 0 ? `Allowed domains: ${config.allowedDomains.join(", ")}` : "",
27
137
  config.allowShellTool
28
- ? `Shell tool is enabled in ${config.commandCwd}. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}. For npm/pip/conda/venv setup, explain the need and wait for approval unless policy is allow.`
138
+ ? `Shell tool is enabled in ${config.commandCwd}. In Docker, this path is mounted as /workspace. Use relative paths or /workspace paths, not absolute host temp paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}. For npm/pip/conda/venv setup, explain the need and wait for approval unless policy is allow.`
139
+ : "",
140
+ config.allowFileTools
141
+ ? `Workspace file tools are enabled in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets.`
29
142
  : "",
30
- config.allowWrapperTools ? `Agent wrappers are enabled: ${wrapperStatusText()}.` : "",
143
+ config.allowWrapperTools
144
+ ? `Agent wrappers are enabled. Use the selected wrapper only: ${normalizeWrapperName(config.preferredWrapper)}. Status: ${wrapperStatusText()}.`
145
+ : "",
146
+ "A canvas/artifacts tunnel is available through send_to_canvas. Use it when an output should be highlighted visually, such as screenshots, image files, important markdown, diffs, or generated artifact paths. It is optional for ordinary text answers.",
147
+ "When the user asks to draw, plot, graph, chart, diagram, create a figure, or visualize something, include a canvas artifact even if the user does not mention canvas. Prefer a small SVG file or concise markdown figure when file tools are available.",
148
+ "When the user asks for LaTeX, TeX, a paper, manuscript, report, or PDF, plan to create the needed source/assets, compile with the available allowlisted TeX toolchain, and publish the PDF through the canvas tunnel. For subfolder documents, keep outputs beside the source. For generated figures, use pdflatex-compatible formats such as PDF or PNG.",
31
149
  "Return a numbered plan only.",
32
150
  ]
33
151
  .filter(Boolean)
@@ -181,17 +299,114 @@ export async function requestNextStep(client, config, messages) {
181
299
  });
182
300
  }
183
301
 
302
+ if (config.allowFileTools) {
303
+ tools.splice(
304
+ -1,
305
+ 0,
306
+ {
307
+ type: "function",
308
+ function: {
309
+ name: "list_files",
310
+ description:
311
+ "List workspace-local files under the configured working directory. Paths must stay inside the workspace; .git, node_modules, sessions, and sensitive files are skipped.",
312
+ parameters: {
313
+ type: "object",
314
+ properties: {
315
+ path: { type: "string", description: "Workspace-relative path to list. Defaults to ." },
316
+ maxDepth: { type: "integer", description: "Recursive depth, 1 to 8." },
317
+ limit: { type: "integer", description: "Maximum entries to return." },
318
+ },
319
+ additionalProperties: false,
320
+ },
321
+ },
322
+ },
323
+ {
324
+ type: "function",
325
+ function: {
326
+ name: "read_file",
327
+ description:
328
+ "Read a small UTF-8 workspace file. Secret paths, .git internals, files outside the workspace, binary files, and huge files are blocked.",
329
+ parameters: {
330
+ type: "object",
331
+ properties: {
332
+ path: { type: "string", description: "Workspace-relative file path." },
333
+ },
334
+ required: ["path"],
335
+ additionalProperties: false,
336
+ },
337
+ },
338
+ },
339
+ {
340
+ type: "function",
341
+ function: {
342
+ name: "search_files",
343
+ description:
344
+ "Search small UTF-8 workspace files for literal text. Secret paths, .git internals, node_modules, and huge files are skipped.",
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ query: { type: "string" },
349
+ path: { type: "string", description: "Workspace-relative directory or file. Defaults to ." },
350
+ caseSensitive: { type: "boolean" },
351
+ maxResults: { type: "integer" },
352
+ },
353
+ required: ["query"],
354
+ additionalProperties: false,
355
+ },
356
+ },
357
+ },
358
+ {
359
+ type: "function",
360
+ function: {
361
+ name: "write_file",
362
+ description:
363
+ "Create or overwrite a small UTF-8 workspace file. Use mode=create for new files and mode=overwrite only after reading/understanding the existing file. Secret paths, .git, node_modules writes, and outside-workspace paths are blocked. The runtime records before/after hashes and a compact diff.",
364
+ parameters: {
365
+ type: "object",
366
+ properties: {
367
+ path: { type: "string", description: "Workspace-relative file path." },
368
+ content: { type: "string" },
369
+ mode: { type: "string", enum: ["create", "overwrite"] },
370
+ },
371
+ required: ["path", "content"],
372
+ additionalProperties: false,
373
+ },
374
+ },
375
+ },
376
+ {
377
+ type: "function",
378
+ function: {
379
+ name: "apply_patch",
380
+ description:
381
+ "Apply a deterministic workspace-local search/replace patch to one small UTF-8 file. Provide exact search and replacement text. The runtime records before/after hashes and a compact diff.",
382
+ parameters: {
383
+ type: "object",
384
+ properties: {
385
+ path: { type: "string", description: "Workspace-relative file path." },
386
+ search: { type: "string" },
387
+ replace: { type: "string" },
388
+ expectedReplacements: { type: "integer" },
389
+ },
390
+ required: ["path", "search", "replace"],
391
+ additionalProperties: false,
392
+ },
393
+ },
394
+ }
395
+ );
396
+ }
397
+
184
398
  if (config.allowWrapperTools) {
399
+ const selectedWrapper = normalizeWrapperName(config.preferredWrapper);
185
400
  tools.splice(-1, 0, {
186
401
  type: "function",
187
402
  function: {
188
403
  name: "delegate_agent",
189
404
  description:
190
- "Ask an installed external coding agent wrapper for advisory help. Use for codebase analysis, implementation strategy, or second-opinion review. The wrapper is instructed to avoid modifying files.",
405
+ `Ask the selected external coding agent wrapper (${selectedWrapper}) for advisory help. Use for codebase analysis, implementation strategy, or second-opinion review. The wrapper is instructed to avoid modifying files.`,
191
406
  parameters: {
192
407
  type: "object",
193
408
  properties: {
194
- wrapper: { type: "string", enum: WRAPPER_NAMES },
409
+ wrapper: { type: "string", enum: [selectedWrapper] },
195
410
  prompt: { type: "string" },
196
411
  },
197
412
  required: ["wrapper", "prompt"],
@@ -201,12 +416,91 @@ export async function requestNextStep(client, config, messages) {
201
416
  });
202
417
  }
203
418
 
419
+ tools.splice(-1, 0, {
420
+ type: "function",
421
+ function: {
422
+ name: "send_to_canvas",
423
+ description:
424
+ "Send an optional artifact notification to the frontend canvas/artifacts tunnel. Use for important markdown/text, generated images, screenshots, figures, diffs, or workspace file paths the user should preview. Proactively use this for draw/plot/graph/chart/diagram/figure requests even when the user did not mention canvas. This does not replace finish.",
425
+ parameters: {
426
+ type: "object",
427
+ properties: {
428
+ title: { type: "string", description: "Short display title for the canvas item." },
429
+ kind: {
430
+ type: "string",
431
+ enum: ["text", "markdown", "image", "json", "diff", "file", "pdf"],
432
+ description: "Renderer hint. Use image/file with path, markdown/text/json/diff with content.",
433
+ },
434
+ content: { type: "string", description: "Inline text or markdown content to render." },
435
+ path: { type: "string", description: "Optional workspace-relative file path to preview." },
436
+ note: { type: "string", description: "Short notification message for the artifact explorer." },
437
+ selected: { type: "boolean", description: "Whether the frontend should select this item immediately." },
438
+ },
439
+ required: ["title", "kind"],
440
+ additionalProperties: false,
441
+ },
442
+ },
443
+ });
444
+
445
+ if (client.mock) {
446
+ const toolPayload = latestToolPayload(messages);
447
+ if (toolPayload) {
448
+ const output = [
449
+ toolPayload.stdout,
450
+ toolPayload.stderr,
451
+ toolPayload.error,
452
+ toolPayload.reason,
453
+ toolPayload.path ? `Path: ${toolPayload.path}` : "",
454
+ toolPayload.change?.diff ? `Diff:\n${toolPayload.change.diff}` : "",
455
+ ]
456
+ .filter(Boolean)
457
+ .join("\n");
458
+ return mockChatResponse("Mock mode finished after receiving the latest tool result.", [
459
+ mockToolCall("finish", {
460
+ result: [
461
+ "Mock run complete.",
462
+ toolPayload.toolName ? `Tool: ${toolPayload.toolName}` : "",
463
+ toolPayload.args?.command ? `Command: ${toolPayload.args.command}` : "",
464
+ toolPayload.blocked ? "Blocked by guardrail." : "",
465
+ output ? `Output:\n${output}` : "No command output was returned.",
466
+ ]
467
+ .filter(Boolean)
468
+ .join("\n"),
469
+ }),
470
+ ]);
471
+ }
472
+
473
+ const canvasTool = mockCanvasToolForGoal(config.goal);
474
+ if (canvasTool) {
475
+ return mockChatResponse("Mock mode will publish a canvas artifact for the UI tunnel.", [canvasTool]);
476
+ }
477
+
478
+ if (config.allowFileTools) {
479
+ const workspaceTool = mockWorkspaceToolForGoal(config.goal);
480
+ if (workspaceTool) {
481
+ return mockChatResponse("Mock mode will exercise a guarded workspace file tool.", [workspaceTool]);
482
+ }
483
+ }
484
+
485
+ if (config.allowShellTool) {
486
+ return mockChatResponse("Mock mode will use the guarded shell tool for a non-dangerous local inspection.", [
487
+ mockToolCall("run_command", { command: mockCommandForGoal(config.goal) }),
488
+ ]);
489
+ }
490
+
491
+ return mockChatResponse("Mock mode can complete without external model credentials.", [
492
+ mockToolCall("finish", {
493
+ result: `Mock run complete for: ${config.goal}`,
494
+ }),
495
+ ]);
496
+ }
497
+
204
498
  return client.chat.completions.create({
205
499
  model: config.model,
206
500
  temperature: 0,
207
501
  tool_choice: "auto",
208
502
  parallel_tool_calls: false,
209
- messages,
503
+ messages: prepareMessages(config, messages),
210
504
  tools,
211
505
  });
212
506
  }
@@ -17,9 +17,28 @@ const COMPLEXITY_KEYWORDS = [
17
17
  "github",
18
18
  ];
19
19
 
20
+ const COMPLEX_ROUTE_HINTS = [
21
+ /\blatex\b/i,
22
+ /\btexlive\b/i,
23
+ /\bpdflatex\b/i,
24
+ /\blatexmk\b/i,
25
+ /\b(manuscript|research paper|white paper|technical report)\b/i,
26
+ /\bcompile\b.*\bpdf\b/i,
27
+ /\bwrite\b.*\bpdf\b/i,
28
+ ];
29
+
20
30
  export const ROUTING_MODES = ["smart", "fast", "complex", "manual"];
21
31
 
22
32
  export function getProviderDefaults(provider = "deepseek") {
33
+ if (provider === "mock") {
34
+ return {
35
+ provider: "mock",
36
+ apiKey: "mock-local",
37
+ baseURL: "",
38
+ model: process.env.MOCK_MODEL || "mock-agent",
39
+ };
40
+ }
41
+
23
42
  if (provider === "openai") {
24
43
  return {
25
44
  provider: "openai",
@@ -53,6 +72,13 @@ export function getModelPresets() {
53
72
  model: process.env.DEEPSEEK_PRO_MODEL || "deepseek-v4-pro",
54
73
  description: "Higher-capacity DeepSeek route for multi-step coding and design tasks.",
55
74
  },
75
+ mock: {
76
+ id: "mock",
77
+ label: "Local mock",
78
+ provider: "mock",
79
+ model: process.env.MOCK_MODEL || "mock-agent",
80
+ description: "Credential-free local route for UI, API, and tool-routing smoke tests.",
81
+ },
56
82
  codexPrimary: {
57
83
  id: "codexPrimary",
58
84
  label: "Codex primary wrapper",
@@ -78,6 +104,9 @@ export function scoreTaskComplexity(goal = "") {
78
104
  for (const keyword of COMPLEXITY_KEYWORDS) {
79
105
  if (text.includes(keyword)) score += 1;
80
106
  }
107
+ for (const hint of COMPLEX_ROUTE_HINTS) {
108
+ if (hint.test(goal)) score += 3;
109
+ }
81
110
  return score;
82
111
  }
83
112
 
@@ -89,6 +118,17 @@ export function selectModelRoute({ routingMode = "smart", provider = "deepseek",
89
118
  const mode = normalizeRoutingMode(routingMode);
90
119
  const presets = getModelPresets();
91
120
 
121
+ if (provider === "mock") {
122
+ const defaults = getProviderDefaults("mock");
123
+ return {
124
+ routingMode: "manual",
125
+ provider: defaults.provider,
126
+ model: model || defaults.model,
127
+ reason: "Local mock route selected for smoke tests and offline UI/API checks.",
128
+ complexityScore: scoreTaskComplexity(goal),
129
+ };
130
+ }
131
+
92
132
  if (mode === "manual") {
93
133
  const defaults = getProviderDefaults(provider);
94
134
  return {
@@ -70,4 +70,8 @@ export class SessionStore {
70
70
  screenshotPath(step) {
71
71
  return path.join(this.artifactsDir, `step-${String(step).padStart(3, "0")}.png`);
72
72
  }
73
+
74
+ async remove() {
75
+ await fs.rm(this.sessionDir, { recursive: true, force: true });
76
+ }
73
77
  }
@@ -6,6 +6,7 @@ import { redactSensitiveText } from "./redaction.js";
6
6
  const execFile = promisify(execFileCallback);
7
7
 
8
8
  export const WRAPPER_NAMES = ["codex", "claude", "gemini", "copilot", "qwen"];
9
+ export const DEFAULT_WRAPPER_NAME = "codex";
9
10
 
10
11
  const BASE_ADVISORY_PROMPT = [
11
12
  "You are being called as an advisory wrapper tool inside AgInTiFlow.",
@@ -92,6 +93,11 @@ export function isKnownWrapper(wrapper) {
92
93
  return WRAPPER_NAMES.includes(wrapper);
93
94
  }
94
95
 
96
+ export function normalizeWrapperName(wrapper, fallback = DEFAULT_WRAPPER_NAME) {
97
+ const candidate = String(wrapper || "").trim().toLowerCase();
98
+ return isKnownWrapper(candidate) ? candidate : fallback;
99
+ }
100
+
95
101
  export function listAgentWrappers() {
96
102
  const presets = getModelPresets();
97
103
  return [