@lazyingart/agintiflow 0.20.175 → 0.20.176

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/README.md CHANGED
@@ -54,7 +54,7 @@ Most agent tools are either a chat box with hidden state or an expensive one-mod
54
54
  | Disciplined by default | `AGINTI.md` starts with a behavior contract: surface ambiguity, keep edits surgical, avoid speculative complexity, verify outcomes, and respect permission blockers. |
55
55
  | Role-based models | Route, main, spare, wrapper, and auxiliary image roles are separate. You can use cheap route models, stronger main models, optional OpenAI/Qwen/Venice routes, and GRS AI/Venice image tools. |
56
56
  | Writing without agent noise | `writing_specialist` drafts novels, books, scripts, essays, and paper prose in an isolated writing-only context, then the main agent handles files, formatting, citations, checks, and artifacts. |
57
- | Visual and web evidence | `read_image` reads screenshots/figures with typed perception artifacts, `web_research` saves sourced research artifacts, and `research_wrapper` can ask Codex `gpt-5.4-mini` medium for a strict-JSON second opinion. |
57
+ | Visual and web evidence | `read_image` reads screenshots/figures with typed JSON and Markdown perception artifacts, uses OpenAI vision when configured, and falls back to `codex exec --image` when Codex is available. `web_research` saves sourced research artifacts, and `research_wrapper` can ask Codex `gpt-5.4-mini` medium for a strict-JSON second opinion. |
58
58
  | Scouts before big work | Parallel scouts can cheaply map architecture, tests, risks, symbols, and integration points before the main executor edits anything. |
59
59
  | SCS by default | Student-Committee-Supervisor mode adds a typed gate: committee drafts, student approves/monitors, supervisor executes. Use `/scs off` or `--no-scs` only when speed matters more than validation. |
60
60
  | AAPS for large workflows | AAPS describes top-down agentic pipeline scripts; AgInTiFlow can act as the interactive backend that validates, compiles, and executes those workflows. |
@@ -239,7 +239,7 @@ The website keeps the visual walkthrough in a carousel so this README can stay f
239
239
  | Writing specialist | A dedicated writing-only LLM call for prose, chapters, scripts, books, essays, research-paper sections, and revisions, with formatter handoff notes for Markdown/LaTeX/Final Draft. |
240
240
  | Patch workflow | Codex-style patch envelopes, unified diffs, exact replacements, hashes, compact diffs, and path guardrails. |
241
241
  | Parallel scouts | Optional scout calls for architecture, implementation, review, tests, git flow, research, symbol tracing, and dependency risk. |
242
- | Image reading and web research | `read_image` uses OpenAI vision for workspace images when `OPENAI_API_KEY` is configured. `web_research` preserves source lists, and optional OpenAI hosted web search or `research_wrapper` can be used for higher-confidence research. |
242
+ | Image reading and web research | `read_image` uses OpenAI vision for workspace images when `OPENAI_API_KEY` is configured, otherwise it can fall back to Codex CLI image attachments when available. It saves JSON and Markdown reports and can surface the report in the canvas. `web_research` preserves source lists, and optional OpenAI hosted web search or `research_wrapper` can be used for higher-confidence research. |
243
243
  | SCS mode | Default Student-Committee-Supervisor quality gate with independent planning, execution, and validation roles. |
244
244
  | AAPS adapter | Optional `@lazyingart/aaps` integration for `.aaps` workflow init, validate, parse, compile, dry-run, and run commands. |
245
245
  | Image generation | Optional GRS AI and Venice image tools with saved manifests and canvas artifact previews. |
@@ -6,7 +6,7 @@ AgInTiFlow separates visual understanding, web search, and wrapper advice so eac
6
6
 
7
7
  | Tool | Purpose | Evidence |
8
8
  | --- | --- | --- |
9
- | `read_image` | Read workspace screenshots, plots, scanned text, diagrams, or allowed remote image URLs. | Saves `artifacts/perception/*-read-image.json` in the central session store with image hashes, MIME type, size, model, prompt, and typed result. |
9
+ | `read_image` | Read workspace screenshots, plots, scanned text, diagrams, or allowed remote image URLs. | Saves JSON and Markdown reports under `artifacts/perception/`, records image hashes, and sends the Markdown report to the canvas when used by an agent run. |
10
10
  | `web_search` | Cheap raw search snippets. | Returns compact titles, URLs, snippets, and fallback search URL. |
11
11
  | `web_research` | Sourced research unit for current or external information. | Saves `artifacts/research/*-web-research.json` with query, mode, source list, and answer. |
12
12
  | `research_wrapper` | Read-only strict-JSON second opinion from the selected wrapper. | Saves `artifacts/wrappers/*-research-wrapper.json` with wrapper, model, reasoning, metadata, result, and raw fallback output. |
@@ -14,7 +14,9 @@ AgInTiFlow separates visual understanding, web search, and wrapper advice so eac
14
14
  ## Defaults
15
15
 
16
16
  - `read_image` uses OpenAI Responses vision when `OPENAI_API_KEY` is configured.
17
- - `read_image` defaults to `AGINTI_PERCEPTION_MODEL=gpt-5.4-mini` and `AGINTI_PERCEPTION_REASONING=medium`, then falls back through `AGINTI_PERCEPTION_FALLBACK_MODELS` or `gpt-4o-mini` if the account lacks access to the preferred model.
17
+ - If OpenAI vision is unavailable, `read_image` falls back to `codex exec --image` when the Codex CLI is installed and wrapper tools are enabled.
18
+ - Wrapper tools default on when Codex is available, and can still be disabled with `/wrapper off` or `--no-wrapper`-style runtime settings.
19
+ - `read_image` defaults to `AGINTI_PERCEPTION_MODEL=gpt-5.4-mini` and `AGINTI_PERCEPTION_REASONING=medium`, then falls back through `AGINTI_PERCEPTION_FALLBACK_MODELS` or `gpt-4o-mini` if the account lacks access to the preferred OpenAI model.
18
20
  - `web_research` defaults to lightweight snippet mode; use `mode=openai` only when hosted OpenAI web search is needed and configured.
19
21
  - `web_research mode=openai` defaults to `AGINTI_WEB_RESEARCH_MODEL=gpt-5.4-mini` / `medium`, then falls back through `AGINTI_WEB_RESEARCH_FALLBACK_MODELS` or `gpt-4o-mini`.
20
22
  - `research_wrapper` defaults to `AGINTI_RESEARCH_WRAPPER_MODEL=gpt-5.4-mini` and `AGINTI_RESEARCH_WRAPPER_REASONING=medium`.
@@ -24,6 +26,7 @@ AgInTiFlow separates visual understanding, web search, and wrapper advice so eac
24
26
 
25
27
  ```text
26
28
  /image-read artifacts/screenshots/app.png what looks wrong?
29
+ /image-read --codex artifacts/screenshots/app.png what looks wrong?
27
30
  /web-research latest Android Gradle plugin official docs
28
31
  /research-wrapper gpt-5.4-mini medium
29
32
  /research-wrapper off
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.175",
3
+ "version": "0.20.176",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -58,7 +58,28 @@ async function main() {
58
58
  assert(image.ok, `read_image dry-run failed: ${image.error || "unknown"}`);
59
59
  assert(image.images?.[0]?.sha256, "read_image did not record image hash");
60
60
  assert(image.artifactPath, "read_image did not persist a perception artifact");
61
+ assert(image.markdownArtifactPath, "read_image did not persist a Markdown perception artifact");
62
+ assert(image.markdownPath, "read_image did not persist a workspace Markdown report");
61
63
  await fs.access(image.artifactPath);
64
+ await fs.access(image.markdownArtifactPath);
65
+ await fs.access(path.join(workspace, image.markdownPath));
66
+
67
+ const codexFallback = await readImage(
68
+ {
69
+ path: "artifacts/screenshots/tiny.png",
70
+ prompt: "Describe this tiny image with the Codex image fallback.",
71
+ provider: "codex",
72
+ codexDryRun: true,
73
+ },
74
+ {
75
+ ...config,
76
+ allowWrapperTools: true,
77
+ },
78
+ store
79
+ );
80
+ assert(codexFallback.ok, `read_image Codex fallback dry-run failed: ${codexFallback.error || "unknown"}`);
81
+ assert(codexFallback.provider === "codex-wrapper-dry-run", "read_image did not select the Codex wrapper fallback path");
82
+ assert(codexFallback.markdownPath, "read_image Codex fallback did not write a Markdown report");
62
83
 
63
84
  const secretBlock = checkToolUse({
64
85
  toolName: "read_image",
@@ -116,6 +137,7 @@ async function main() {
116
137
  const runStore = new SessionStore(agentConfig.sessionsDir, run.sessionId);
117
138
  const events = await runStore.loadEvents();
118
139
  assert(events.some((event) => event.type === "tool.completed" && event.data?.toolName === "read_image"), "mock agent did not call read_image");
140
+ assert(events.some((event) => event.type === "canvas.item" && event.data?.toolName === "read_image"), "read_image did not send the Markdown report to canvas");
119
141
 
120
142
  await fs.rm(tempRoot, { recursive: true, force: true });
121
143
  console.log("smoke-perception-research ok");
@@ -1775,6 +1775,40 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
1775
1775
  const eventResult = sanitizeToolResult(result);
1776
1776
  await store.appendEvent(result.ok ? "tool.completed" : "tool.failed", eventResult);
1777
1777
  observers.event(result.ok ? "tool.completed" : "tool.failed", eventResult);
1778
+ if (result.ok && result.markdownPath) {
1779
+ const normalized = normalizeCanvasPayload(
1780
+ {
1781
+ title: "Image reading report",
1782
+ kind: "markdown",
1783
+ path: result.markdownPath,
1784
+ note: result.result?.summary || result.result?.answer || "Image reading report.",
1785
+ selected: true,
1786
+ },
1787
+ config
1788
+ );
1789
+ if (normalized.ok) {
1790
+ const persisted = await persistCanvasPayloadFile(normalized.payload, { config, store });
1791
+ if (persisted.ok) {
1792
+ const canvasItem = {
1793
+ ...persisted.payload,
1794
+ toolName: "read_image",
1795
+ commandCwd: config.commandCwd,
1796
+ };
1797
+ await store.appendEvent("canvas.item", canvasItem);
1798
+ observers.event("canvas.item", canvasItem);
1799
+ await store.appendEvent("canvas.selected", {
1800
+ artifactId: canvasItem.artifactId,
1801
+ title: canvasItem.title,
1802
+ source: "read_image",
1803
+ });
1804
+ observers.event("canvas.selected", {
1805
+ artifactId: canvasItem.artifactId,
1806
+ title: canvasItem.title,
1807
+ source: "read_image",
1808
+ });
1809
+ }
1810
+ }
1811
+ }
1778
1812
  return result;
1779
1813
  }
1780
1814
  case "json_specialist": {
package/src/config.js CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import crypto from "node:crypto";
3
3
  import { getModelRoleDefaults, getProviderDefaults, normalizeRoutingMode, selectModelRoute } from "./model-routing.js";
4
4
  import { normalizePackageInstallPolicy, normalizeSandboxMode } from "./command-policy.js";
5
- import { normalizeWrapperName } from "./tool-wrappers.js";
5
+ import { isWrapperAvailable, normalizeWrapperName } from "./tool-wrappers.js";
6
6
  import { loadProjectEnv, projectPaths, resolveProjectRoot } from "./project.js";
7
7
  import { normalizeTaskProfile } from "./task-profiles.js";
8
8
  import { recommendedMaxStepsForTask } from "./engineering-guidance.js";
@@ -113,6 +113,10 @@ export function resolveRuntimeConfig(args, overrides = {}) {
113
113
  overrides.allowParallelScouts ?? args.allowParallelScouts ?? process.env.AGINTI_PARALLEL_SCOUTS,
114
114
  true
115
115
  );
116
+ const preferredWrapper = normalizeWrapperName(
117
+ overrides.preferredWrapper ?? args.preferredWrapper ?? process.env.PREFERRED_WRAPPER ?? process.env.AGENT_WRAPPER
118
+ );
119
+ const wrapperDefaultEnabled = isWrapperAvailable(preferredWrapper) || isWrapperAvailable("codex");
116
120
 
117
121
  return {
118
122
  ...defaults,
@@ -192,7 +196,7 @@ export function resolveRuntimeConfig(args, overrides = {}) {
192
196
  ),
193
197
  allowWrapperTools: parseBoolean(
194
198
  overrides.allowWrapperTools ?? args.allowWrapperTools ?? process.env.ALLOW_WRAPPER_TOOLS,
195
- false
199
+ wrapperDefaultEnabled
196
200
  ),
197
201
  allowAuxiliaryTools: parseBoolean(
198
202
  overrides.allowAuxiliaryTools ?? args.allowAuxiliaryTools ?? process.env.ALLOW_AUXILIARY_TOOLS,
@@ -206,9 +210,7 @@ export function resolveRuntimeConfig(args, overrides = {}) {
206
210
  1,
207
211
  10
208
212
  ),
209
- preferredWrapper: normalizeWrapperName(
210
- overrides.preferredWrapper ?? args.preferredWrapper ?? process.env.PREFERRED_WRAPPER ?? process.env.AGENT_WRAPPER
211
- ),
213
+ preferredWrapper,
212
214
  wrapperTimeoutMs: parseNumber(overrides.wrapperTimeoutMs ?? process.env.WRAPPER_TIMEOUT_MS, 120000),
213
215
  permissionMode,
214
216
  sandboxMode,
@@ -47,6 +47,7 @@ import {
47
47
  } from "./permission-modes.js";
48
48
  import { ensureAgintiWebApp, readWebAppPreference, stopAgintiWebApp, writeWebAppPreference } from "./web-autostart.js";
49
49
  import { mcpCliCommand, formatMcpCliResult } from "./mcp/tool-bridge.js";
50
+ import { isWrapperAvailable } from "./tool-wrappers.js";
50
51
 
51
52
  const useColor = Boolean(input.isTTY && output.isTTY && process.env.AGINTIFLOW_NO_COLOR !== "1");
52
53
  const ansi = {
@@ -1112,7 +1113,7 @@ function printHelp() {
1112
1113
  ` ${command("/webapp [port|start|stop|restart|reuse|enable|disable|status]", "Start, reuse, stop, restart, or configure the local webapp.", "helpWebapp")}`,
1113
1114
  ` ${command("/web-search on|off", "Enable or disable the web_search tool.", "helpWebSearch")}`,
1114
1115
  ` ${command("/web-research <query>", "Run a sourced web_research turn with persisted evidence.", "helpWebSearch")}`,
1115
- ` ${command("/image-read <path> [question]", "Run read_image on a workspace screenshot/image.", "helpWebSearch")}`,
1116
+ ` ${command("/image-read [--codex|--openai] <path> [question]", "Run read_image on a workspace screenshot/image.", "helpWebSearch")}`,
1116
1117
  ` ${command("/research-wrapper [on|off|model reasoning]", "Configure strict JSON wrapper research, default gpt-5.4-mini medium.", "helpWrapper")}`,
1117
1118
  ` ${command("/scs [on|auto|off|status]", "Toggle Student-Committee-Supervisor gated execution.", "helpEnableScs")}`,
1118
1119
  ` ${command("/scouts on|off|<1-10>", "Enable parallel DeepSeek scouts and set scout count.", "helpScouts")}`,
@@ -2659,7 +2660,7 @@ function createState(args = {}) {
2659
2660
  allowParallelScouts: args.allowParallelScouts ?? true,
2660
2661
  enableScs: normalizeScsMode(args.enableScs || process.env.AGINTI_SCS_MODE || "on"),
2661
2662
  parallelScoutCount: args.parallelScoutCount || 3,
2662
- allowWrapperTools: args.allowWrapperTools ?? false,
2663
+ allowWrapperTools: args.allowWrapperTools ?? isWrapperAvailable(args.preferredWrapper || "codex"),
2663
2664
  allowDestructive: args.allowDestructive ?? permissionDefaults.allowDestructive ?? false,
2664
2665
  allowPasswords: args.allowPasswords ?? permissionDefaults.allowPasswords ?? false,
2665
2666
  allowOutsideWorkspaceFileTools:
@@ -3844,21 +3845,27 @@ async function handleCommand(line, state, packageDir) {
3844
3845
  return true;
3845
3846
  }
3846
3847
  if (command === "image-read") {
3847
- const [target, ...questionParts] = value.split(/\s+/).filter(Boolean);
3848
+ const rawParts = value.split(/\s+/).filter(Boolean);
3849
+ const providerFlag = ["--codex", "--openai"].includes(rawParts[0]) ? rawParts.shift() : "";
3850
+ const provider = providerFlag ? providerFlag.replace(/^--/, "") : "";
3851
+ const [target, ...questionParts] = rawParts;
3848
3852
  if (!target) {
3849
- printAgentMessage("Usage: /image-read <workspace-image-path-or-url> [question]");
3853
+ printAgentMessage("Usage: /image-read [--codex|--openai] <workspace-image-path-or-url> [question]");
3850
3854
  return true;
3851
3855
  }
3852
3856
  const previousProfile = state.taskProfile;
3853
3857
  const previousMaxSteps = state.maxSteps;
3858
+ const previousWrapperTools = state.allowWrapperTools;
3854
3859
  try {
3855
3860
  state.taskProfile = "image";
3856
3861
  state.maxSteps = Math.max(state.maxSteps, 12);
3862
+ if (provider === "codex") state.allowWrapperTools = true;
3857
3863
  await runPrompt(
3858
3864
  [
3859
3865
  `Use the read_image tool on ${target}.`,
3866
+ provider ? `Use read_image provider=${provider}.` : "Use read_image provider=auto so it can use OpenAI vision or Codex fallback.",
3860
3867
  questionParts.length ? `Question: ${questionParts.join(" ")}` : "Question: describe the image accurately and report visible text, issues, and uncertainty.",
3861
- "Do not infer from the filename. Report the read_image artifact path.",
3868
+ "Do not infer from the filename. Report the read_image JSON and Markdown artifact paths.",
3862
3869
  ].join("\n"),
3863
3870
  state,
3864
3871
  packageDir
@@ -3866,6 +3873,7 @@ async function handleCommand(line, state, packageDir) {
3866
3873
  } finally {
3867
3874
  state.taskProfile = previousProfile;
3868
3875
  state.maxSteps = previousMaxSteps;
3876
+ state.allowWrapperTools = previousWrapperTools;
3869
3877
  }
3870
3878
  return true;
3871
3879
  }
@@ -723,7 +723,7 @@ export async function createPlan(client, config, state) {
723
723
  "For substantial writing work such as novels, chapters, books, scripts, essays, LaTeX manuscripts, or research-paper prose, plan to call writing_specialist with only the writing brief/canon/style/draft context. The main agent should handle files, citations, checks, and Markdown/LaTeX/Final Draft formatting after the isolated writing draft returns.",
724
724
  "For repetitive schema-bound extraction, annotation, conversion, or validation tasks, use json_specialist with only the task, input, schema, and focused instructions. It calls the model directly for strict JSON, tries provider-native structured output when supported, and keeps agent/runtime/tool context out of the specialist prompt.",
725
725
  config.allowFileTools
726
- ? "read_image is available for workspace-local or allowed remote screenshots/images using OpenAI vision when OPENAI_API_KEY is configured. It returns typed visual observations and persists a perception artifact; if credentials are missing, report the blocker instead of guessing from the filename."
726
+ ? "read_image is available for workspace-local or allowed remote screenshots/images. It tries OpenAI vision first when OPENAI_API_KEY is configured, then Codex CLI image wrapper when available and wrappers are enabled. It returns typed visual observations and persists JSON plus Markdown artifacts; never guess from filenames."
727
727
  : "",
728
728
  config.allowParallelScouts
729
729
  ? `Parallel scout notes may be injected before execution for complex tasks. Scout count: ${config.parallelScoutCount}.`
@@ -1179,7 +1179,7 @@ export async function requestNextStep(client, config, messages) {
1179
1179
  function: {
1180
1180
  name: "read_image",
1181
1181
  description:
1182
- "Read and describe workspace-local screenshots/images or allowed remote image URLs using OpenAI vision. Use for UI screenshots, plots, microscopy images, scanned text, diagrams, and visual debugging. It records hashes and persists a typed perception artifact. Defaults to gpt-5.4-mini with gpt-4o-mini fallback when needed. Requires OPENAI_API_KEY; if unavailable, report that blocker.",
1182
+ "Read and describe workspace-local screenshots/images or allowed remote image URLs. Use for UI screenshots, plots, microscopy images, scanned text, diagrams, and visual debugging. It records hashes and persists JSON plus Markdown perception artifacts. Defaults to OpenAI vision, then falls back to the Codex image wrapper when Codex CLI is available and wrappers are enabled.",
1183
1183
  parameters: {
1184
1184
  type: "object",
1185
1185
  properties: {
@@ -1191,8 +1191,9 @@ export async function requestNextStep(client, config, messages) {
1191
1191
  description: "Optional multiple workspace-relative image paths or HTTPS image URLs, maximum 4.",
1192
1192
  },
1193
1193
  prompt: { type: "string", description: "Question or reading instruction for the image(s)." },
1194
+ provider: { type: "string", enum: ["auto", "openai", "codex"], description: "Perception backend. auto tries OpenAI then Codex wrapper." },
1194
1195
  detail: { type: "string", enum: ["low", "high", "auto"], description: "Vision detail level. Defaults to auto." },
1195
- model: { type: "string", description: "Optional OpenAI vision model. Defaults to AGINTI_PERCEPTION_MODEL or gpt-5.4-mini." },
1196
+ model: { type: "string", description: "Optional perception model. OpenAI defaults to AGINTI_PERCEPTION_MODEL; Codex defaults to the research wrapper model." },
1196
1197
  reasoning: { type: "string", enum: ["low", "medium", "high", "xhigh"], description: "Optional reasoning effort. Defaults to medium." },
1197
1198
  },
1198
1199
  additionalProperties: false,
@@ -1577,6 +1578,8 @@ export async function requestNextStep(client, config, messages) {
1577
1578
  toolPayload.error,
1578
1579
  toolPayload.reason,
1579
1580
  toolPayload.summary ? `Summary: ${toolPayload.summary}` : "",
1581
+ toolPayload.result?.summary ? `Summary: ${toolPayload.result.summary}` : "",
1582
+ toolPayload.result?.answer ? `Answer: ${toolPayload.result.answer}` : "",
1580
1583
  toolPayload.counts ? `Counts: ${JSON.stringify(toolPayload.counts)}` : "",
1581
1584
  Array.isArray(toolPayload.recommendedReads) && toolPayload.recommendedReads.length
1582
1585
  ? `Recommended reads: ${toolPayload.recommendedReads.join(", ")}`
@@ -1585,6 +1588,8 @@ export async function requestNextStep(client, config, messages) {
1585
1588
  ? `Results:\n${toolPayload.results.map((item, index) => `${index + 1}. ${item.title} ${item.url}`).join("\n")}`
1586
1589
  : "",
1587
1590
  toolPayload.path ? `Path: ${toolPayload.path}` : "",
1591
+ toolPayload.markdownPath ? `Markdown: ${toolPayload.markdownPath}` : "",
1592
+ toolPayload.artifactPath ? `Artifact: ${toolPayload.artifactPath}` : "",
1588
1593
  Array.isArray(toolPayload.changes)
1589
1594
  ? toolPayload.changes
1590
1595
  .map((change) => [change.path ? `Path: ${change.path}` : "", change.diff ? `Diff:\n${change.diff}` : ""].filter(Boolean).join("\n"))
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import OpenAI from "openai";
5
5
  import { isDomainAllowed } from "./guardrails.js";
6
6
  import { redactSensitiveText } from "./redaction.js";
7
- import { normalizeWrapperName, runAgentWrapper } from "./tool-wrappers.js";
7
+ import { isWrapperAvailable, normalizeWrapperName, runAgentWrapper, runCodexImageWrapper } from "./tool-wrappers.js";
8
8
  import { searchWeb } from "./web-search.js";
9
9
  import { resolveWorkspacePath } from "./workspace-tools.js";
10
10
 
@@ -154,6 +154,92 @@ async function persistToolArtifact(store, subdir, stem, payload) {
154
154
  return filePath;
155
155
  }
156
156
 
157
+ function markdownList(items = []) {
158
+ const values = Array.isArray(items) ? items.map((item) => String(item || "").trim()).filter(Boolean) : [];
159
+ return values.length ? values.map((item) => `- ${item}`).join("\n") : "- None recorded.";
160
+ }
161
+
162
+ function imageReadMarkdown(payload = {}) {
163
+ const result = payload.result || {};
164
+ const images = Array.isArray(payload.images) ? payload.images : [];
165
+ return [
166
+ "# Image Reading Report",
167
+ "",
168
+ `- Status: ${payload.ok ? "ok" : "failed"}`,
169
+ `- Provider: ${payload.provider || ""}`,
170
+ payload.wrapper ? `- Wrapper: ${payload.wrapper}` : "",
171
+ payload.model ? `- Model: ${payload.model}` : "",
172
+ payload.reasoning ? `- Reasoning: ${payload.reasoning}` : "",
173
+ payload.detail ? `- Detail: ${payload.detail}` : "",
174
+ payload.prompt ? `- Prompt: ${payload.prompt}` : "",
175
+ payload.error ? `- Error: ${payload.error}` : "",
176
+ "",
177
+ "## Images",
178
+ "",
179
+ images.length
180
+ ? images
181
+ .map(
182
+ (image, index) =>
183
+ `${index + 1}. ${image.path || image.url || "image"} (${image.mime || "unknown"}, ${image.sizeBytes || 0} bytes, sha256=${image.sha256 || ""})`
184
+ )
185
+ .join("\n")
186
+ : "No loaded images were recorded.",
187
+ "",
188
+ "## Summary",
189
+ "",
190
+ result.summary || payload.rawText || payload.error || "No summary was returned.",
191
+ "",
192
+ "## Answer",
193
+ "",
194
+ result.answer || result.summary || payload.rawText || payload.error || "No answer was returned.",
195
+ "",
196
+ "## Visible Text",
197
+ "",
198
+ markdownList(result.visibleText),
199
+ "",
200
+ "## Observations",
201
+ "",
202
+ markdownList(result.observations),
203
+ "",
204
+ "## Issues",
205
+ "",
206
+ markdownList(result.issues),
207
+ "",
208
+ "## Uncertainty",
209
+ "",
210
+ markdownList(result.uncertainty || result.uncertainties),
211
+ "",
212
+ ]
213
+ .join("\n")
214
+ .replace(/\n{3,}/g, "\n\n")
215
+ .trimEnd() + "\n";
216
+ }
217
+
218
+ async function persistMarkdownArtifact(store, config, subdir, stem, payload) {
219
+ const content = imageReadMarkdown(payload);
220
+ const filename = `${isoStamp()}-${stem}.md`;
221
+ let markdownArtifactPath = "";
222
+ let markdownPath = "";
223
+
224
+ if (store?.artifactsDir) {
225
+ await store.ensure();
226
+ const outputDir = path.join(store.artifactsDir, subdir);
227
+ await fs.mkdir(outputDir, { recursive: true });
228
+ markdownArtifactPath = path.join(outputDir, filename);
229
+ await fs.writeFile(markdownArtifactPath, content, "utf8");
230
+ }
231
+
232
+ if (config?.commandCwd) {
233
+ const workspaceDir = path.join(config.commandCwd, "artifacts", subdir);
234
+ await fs.mkdir(workspaceDir, { recursive: true });
235
+ const workspacePath = path.join(workspaceDir, filename);
236
+ await fs.writeFile(workspacePath, content, "utf8");
237
+ markdownPath = path.relative(config.commandCwd, workspacePath).replace(/\\/g, "/");
238
+ }
239
+
240
+ return { markdownArtifactPath, markdownPath };
241
+ }
242
+
157
243
  function imageMimeForPath(inputPath, contentType = "") {
158
244
  const type = String(contentType || "").split(";")[0].trim().toLowerCase();
159
245
  if (type.startsWith("image/")) return type;
@@ -310,17 +396,132 @@ async function callOpenAiImageRead(args, images, config) {
310
396
  };
311
397
  }
312
398
 
399
+ function codexImageReadPrompt(args, images) {
400
+ return [
401
+ "Inspect the attached image(s) directly and return strict JSON only. Do not wrap in Markdown.",
402
+ "Use this schema:",
403
+ JSON.stringify({
404
+ summary: "short factual summary",
405
+ visibleText: ["OCR text or labels, empty if none"],
406
+ observations: ["concrete visible details"],
407
+ issues: ["possible UI/data/quality issues, empty if none"],
408
+ answer: "direct answer to the user's question",
409
+ uncertainty: ["limits, ambiguity, or details not visible"],
410
+ }),
411
+ "Rules: describe only visible evidence. Do not infer from filenames. If uncertain, say so.",
412
+ `User question: ${String(args.prompt || args.question || "Describe the image accurately.").trim()}`,
413
+ `Image metadata: ${images
414
+ .map((image, index) => `${index + 1}. ${image.path || image.url} ${image.mime} ${image.sizeBytes} bytes sha256=${image.sha256}`)
415
+ .join("; ")}`,
416
+ ].join("\n");
417
+ }
418
+
419
+ async function codexReadableImagePaths(images, store) {
420
+ const imagePaths = [];
421
+ for (const image of images) {
422
+ if (image.absolutePath) {
423
+ imagePaths.push(image.absolutePath);
424
+ continue;
425
+ }
426
+ if (!store?.artifactsDir) {
427
+ throw new Error("Codex image fallback needs a local image file; remote image copy store is unavailable.");
428
+ }
429
+ await store.ensure();
430
+ const ext = image.mime === "image/jpeg" ? ".jpg" : image.mime === "image/webp" ? ".webp" : image.mime === "image/gif" ? ".gif" : ".png";
431
+ const outputDir = path.join(store.artifactsDir, "perception", "codex-inputs");
432
+ await fs.mkdir(outputDir, { recursive: true });
433
+ const outputPath = path.join(outputDir, `${isoStamp()}-${image.sha256.slice(0, 12)}${ext}`);
434
+ const raw = Buffer.from(String(image.dataUrl || "").replace(/^data:image\/[a-z0-9.+-]+;base64,/i, ""), "base64");
435
+ if (!raw.length) throw new Error("Remote image copy for Codex fallback was empty.");
436
+ await fs.writeFile(outputPath, raw);
437
+ imagePaths.push(outputPath);
438
+ }
439
+ return imagePaths;
440
+ }
441
+
442
+ async function callCodexImageRead(args, images, config, store, priorError = null) {
443
+ if (args.codexDryRun) {
444
+ return {
445
+ provider: "codex-wrapper-dry-run",
446
+ wrapper: "codex",
447
+ model: args.model || config.researchWrapperModel || "gpt-5.4-mini",
448
+ reasoning: args.reasoning || config.researchWrapperReasoning || "medium",
449
+ rawText: "",
450
+ parsed: {
451
+ summary: "Codex image fallback dry run",
452
+ visibleText: [],
453
+ observations: ["Codex image wrapper fallback path was selected."],
454
+ issues: [],
455
+ answer: "dry run",
456
+ uncertainty: [],
457
+ },
458
+ };
459
+ }
460
+ if (config.allowWrapperTools === false && args.provider !== "codex") {
461
+ throw new Error("Codex image fallback is disabled because wrapper tools are off.");
462
+ }
463
+ if (!isWrapperAvailable("codex")) {
464
+ throw new Error("Codex image fallback is unavailable because the Codex CLI was not found on PATH.");
465
+ }
466
+ const imagePaths = await codexReadableImagePaths(images, store);
467
+ const wrapperConfig = {
468
+ ...config,
469
+ wrapperModel: args.model || config.researchWrapperModel || process.env.AGINTI_RESEARCH_WRAPPER_MODEL || "gpt-5.4-mini",
470
+ wrapperReasoning: args.reasoning || config.researchWrapperReasoning || process.env.AGINTI_RESEARCH_WRAPPER_REASONING || "medium",
471
+ };
472
+ const wrapperResult = await runCodexImageWrapper(
473
+ {
474
+ prompt: [
475
+ codexImageReadPrompt(args, images),
476
+ priorError ? `OpenAI vision fallback reason: ${redactSensitiveText(priorError instanceof Error ? priorError.message : String(priorError))}` : "",
477
+ ]
478
+ .filter(Boolean)
479
+ .join("\n\n"),
480
+ imagePaths,
481
+ },
482
+ wrapperConfig
483
+ );
484
+ if (!wrapperResult.ok) throw new Error(wrapperResult.error || "Codex image fallback failed.");
485
+ const parsed = firstJsonObject(wrapperResult.stdout || "");
486
+ return {
487
+ provider: "codex-wrapper",
488
+ wrapper: "codex",
489
+ model: wrapperResult.model || wrapperConfig.wrapperModel,
490
+ reasoning: wrapperResult.reasoning || wrapperConfig.wrapperReasoning,
491
+ rawText: compactText(wrapperResult.stdout || ""),
492
+ parsed,
493
+ stderr: compactText(wrapperResult.stderr || "", 4000),
494
+ fallback: Boolean(priorError || wrapperResult.fallback),
495
+ fallbackReason: priorError ? redactSensitiveText(priorError instanceof Error ? priorError.message : String(priorError)) : "",
496
+ };
497
+ }
498
+
499
+ async function callImageRead(args, images, config, store) {
500
+ const provider = String(args.provider || args.engine || "auto").trim().toLowerCase();
501
+ if (args.dryRun) {
502
+ return { provider: "dry-run", rawText: "", parsed: { summary: "read_image dry run", answer: "dry run" } };
503
+ }
504
+ if (provider === "codex") return callCodexImageRead(args, images, config, store);
505
+ if (provider === "openai") return callOpenAiImageRead(args, images, config);
506
+ try {
507
+ return await callOpenAiImageRead(args, images, config);
508
+ } catch (error) {
509
+ return callCodexImageRead(args, images, config, store, error);
510
+ }
511
+ }
512
+
313
513
  export async function readImage(args = {}, config = {}, store = null) {
314
514
  const loadedImages = [];
315
515
  let payload = null;
316
516
  try {
317
517
  const images = await loadImageInputs(args, config);
318
518
  loadedImages.push(...images);
319
- const analysis = args.dryRun ? { provider: "dry-run", rawText: "", parsed: { summary: "read_image dry run", answer: "dry run" } } : await callOpenAiImageRead(args, images, config);
519
+ const analysis = await callImageRead(args, images, config, store);
320
520
  payload = {
321
521
  ok: true,
322
522
  toolName: "read_image",
323
523
  provider: analysis.provider,
524
+ wrapper: analysis.wrapper || "",
324
525
  model: analysis.model || "",
325
526
  reasoning: analysis.reasoning || "",
326
527
  detail: analysis.detail || "",
@@ -328,8 +529,12 @@ export async function readImage(args = {}, config = {}, store = null) {
328
529
  images: images.map(({ dataUrl, absolutePath, ...image }) => image),
329
530
  result: analysis.parsed || { summary: analysis.rawText, answer: analysis.rawText, uncertainty: ["Model output was not valid JSON."] },
330
531
  rawText: analysis.parsed ? "" : analysis.rawText,
532
+ stderr: analysis.stderr || "",
533
+ fallback: Boolean(analysis.fallback),
534
+ fallbackReason: analysis.fallbackReason || "",
331
535
  };
332
536
  payload.artifactPath = await persistToolArtifact(store, "perception", "read-image", payload);
537
+ Object.assign(payload, await persistMarkdownArtifact(store, config, "perception", "read-image", payload));
333
538
  return payload;
334
539
  } catch (error) {
335
540
  payload = {
@@ -339,6 +544,7 @@ export async function readImage(args = {}, config = {}, store = null) {
339
544
  images: loadedImages.map(({ dataUrl, absolutePath, ...image }) => image),
340
545
  };
341
546
  payload.artifactPath = await persistToolArtifact(store, "perception", "read-image-failed", payload);
547
+ Object.assign(payload, await persistMarkdownArtifact(store, config, "perception", "read-image-failed", payload));
342
548
  return payload;
343
549
  }
344
550
  }
@@ -5,6 +5,7 @@ import {
5
5
  buildScsEvidenceLedger,
6
6
  deriveScsTaskContract,
7
7
  deterministicFinishBlocker,
8
+ evaluateScsSemanticContract,
8
9
  evaluateScsEvidence,
9
10
  finishResultClaimsBlocker,
10
11
  hasScsBlockerEvidence,
@@ -140,6 +141,25 @@ function fallbackBlockedPlan(goal = "", studentReason = "") {
140
141
  .join("\n");
141
142
  }
142
143
 
144
+ function fallbackHardContractPlan(goal = "", contract = {}, studentReason = "") {
145
+ const exactOutputPaths = normalizeStringList(contract.exactOutputPaths, []);
146
+ const requiredTextTerms = normalizeStringList(contract.requiredTextTerms, []);
147
+ const forbiddenTextTerms = normalizeStringList(contract.forbiddenTextTerms, []);
148
+ return [
149
+ "1. Execute the user's target work under the deterministic hard-contract fallback plan.",
150
+ exactOutputPaths.length
151
+ ? `2. Write the requested output exactly at: ${exactOutputPaths.join(", ")}. If an output file already exists and the user allowed overwrite/update, overwrite it intentionally.`
152
+ : "2. Create or update the requested output artifact at the user-specified location.",
153
+ requiredTextTerms.length ? `3. Ensure the output contains these required term(s): ${requiredTextTerms.join(", ")}.` : "",
154
+ forbiddenTextTerms.length ? `4. Ensure the output does not contain these forbidden term(s): ${forbiddenTextTerms.join(", ")}.` : "",
155
+ "5. Run concrete validation commands or inspections for file existence and content before finish.",
156
+ studentReason ? `6. Preserve the validator concern while executing: ${compact(studentReason, 180)}` : "",
157
+ goal ? `7. Original goal remains authoritative: ${compact(goal, 220)}` : "",
158
+ ]
159
+ .filter(Boolean)
160
+ .join("\n");
161
+ }
162
+
143
163
  function parseJsonObject(content = "") {
144
164
  const text = String(content || "").trim();
145
165
  if (!text) return null;
@@ -222,6 +242,47 @@ function normalizePlanText(plan) {
222
242
  return redacted.length <= 1800 ? redacted : `${redacted.slice(0, 1776)} ... [truncated]`;
223
243
  }
224
244
 
245
+ function formatHardContractForPrompt(contract = {}) {
246
+ const lines = [];
247
+ const exactOutputPaths = normalizeStringList(contract.exactOutputPaths, []);
248
+ const requiredTextTerms = normalizeStringList(contract.requiredTextTerms, []);
249
+ const forbiddenTextTerms = normalizeStringList(contract.forbiddenTextTerms, []);
250
+ const forbiddenActions = normalizeStringList(contract.forbiddenActions, []);
251
+ if (exactOutputPaths.length) lines.push(`Exact output path(s): ${exactOutputPaths.join(", ")}`);
252
+ if (requiredTextTerms.length) lines.push(`Required text term(s) in the output: ${requiredTextTerms.join(", ")}`);
253
+ if (forbiddenTextTerms.length) lines.push(`Forbidden text term(s) in the output: ${forbiddenTextTerms.join(", ")}`);
254
+ if (forbiddenActions.length) lines.push(`Forbidden action(s): ${forbiddenActions.join("; ")}`);
255
+ return lines.length
256
+ ? [
257
+ "Inferred hard task contract. Preserve these literally; do not replace them with weaker or contradictory criteria:",
258
+ ...lines.map((line) => `- ${line}`),
259
+ ].join("\n")
260
+ : "";
261
+ }
262
+
263
+ function deterministicPlanContractIssue(committee = {}, contract = {}) {
264
+ const planText = `${committee.phaseGoal || ""}\n${committee.plan || ""}\n${(committee.acceptanceCriteria || []).join("\n")}`;
265
+ const missingPath = normalizeStringList(contract.exactOutputPaths, []).filter((item) => !planText.includes(item));
266
+ const missingRequiredTerms = normalizeStringList(contract.requiredTextTerms, []).filter((item) => !planText.includes(item));
267
+ const forbiddenTermsInPlan = normalizeStringList(contract.forbiddenTextTerms, []).filter((item) => planText.includes(item));
268
+ if (missingPath.length || missingRequiredTerms.length || forbiddenTermsInPlan.length) {
269
+ return {
270
+ decision: "veto_plan",
271
+ confidence: 0.94,
272
+ evidence: [
273
+ missingPath.length ? `Plan omitted exact output path(s): ${missingPath.join(", ")}` : "",
274
+ missingRequiredTerms.length ? `Plan omitted required text term(s): ${missingRequiredTerms.join(", ")}` : "",
275
+ forbiddenTermsInPlan.length ? `Plan includes forbidden text term(s): ${forbiddenTermsInPlan.join(", ")}` : "",
276
+ ].filter(Boolean),
277
+ reason:
278
+ "The phase plan does not preserve the user's inferred hard task contract. It must carry exact paths and required/forbidden output terms into acceptance criteria.",
279
+ next_required_action:
280
+ "Committee must draft a new plan whose acceptance criteria explicitly include every exact output path, required text term, and forbidden text term from the hard contract.",
281
+ };
282
+ }
283
+ return null;
284
+ }
285
+
225
286
  function isBrowserSubmitGoal(goal = "") {
226
287
  const text = String(goal || "");
227
288
  const browserSignal =
@@ -345,6 +406,7 @@ export function buildScsEvidencePack(state = {}, context = {}) {
345
406
  export function buildSupervisorInstruction(scs = {}) {
346
407
  const criteria = normalizeStringList(scs.acceptanceCriteria);
347
408
  const stopConditions = normalizeStringList(scs.stopConditions);
409
+ const hardContract = formatHardContractForPrompt(scs.taskContract || {});
348
410
  return [
349
411
  "SCS mode is enabled. SCS means Student-Committee-Supervisor; do not redefine the acronym.",
350
412
  "You are the supervisor executor in that Student-Committee-Supervisor pipeline.",
@@ -355,6 +417,7 @@ export function buildSupervisorInstruction(scs = {}) {
355
417
  "If tool evidence invalidates the plan, stop repeating the failed path and explain the blocker through finish or wait for student review.",
356
418
  "Approved phase plan:",
357
419
  scs.plan || fallbackPlan(),
420
+ hardContract,
358
421
  criteria.length ? `Acceptance criteria:\n${criteria.map((item) => `- ${item}`).join("\n")}` : "",
359
422
  stopConditions.length ? `Stop conditions:\n${stopConditions.map((item) => `- ${item}`).join("\n")}` : "",
360
423
  "Before calling finish, include concrete evidence: files changed, commands/checks run, artifacts created, or a clear limitation.",
@@ -376,6 +439,7 @@ function committeeSystemPrompt({ phaseKind = "initial" } = {}) {
376
439
  : "",
377
440
  browserStateReconciliationGuidance(),
378
441
  formatBehaviorContractForPrompt({ mode: "plan" }),
442
+ "Preserve exact user hard constraints literally: output paths, required words/phrases, forbidden words/phrases, model/duration/tier, and explicit no-action instructions. Do not invent contradictory acceptance criteria such as a different length target or output location.",
379
443
  "Return strict JSON with keys: role, phase_goal, plan, acceptance_criteria, allowed_tools, stop_conditions.",
380
444
  ]
381
445
  .filter(Boolean)
@@ -389,6 +453,7 @@ function studentPlanGatePrompt() {
389
453
  "Judge whether the committee phase plan is safe, scoped, minimal, permission-aware, and evidence-oriented.",
390
454
  "You cannot execute tools or approve your own work. If the plan is weak, veto it so the committee must draft a better plan.",
391
455
  "For browser tasks, reject plans that stop merely because a state field is unknown when the user requested a target state and a bounded set-then-verify path is available.",
456
+ "Reject any plan whose phase goal, steps, or acceptance criteria omit exact output paths, required output phrases, or forbidden output phrases from the hard task contract. Also reject plans that add contradictory constraints not requested by the user.",
392
457
  formatBehaviorContractForPrompt({ mode: "plan" }),
393
458
  "Return strict JSON with keys: role, decision, confidence, evidence, reason, next_required_action.",
394
459
  ].join(" ");
@@ -468,6 +533,11 @@ async function createScsPhase(client, config, state, context = {}, options = {})
468
533
  "approve_plan"
469
534
  );
470
535
  let lastValidatorConcern = validatorFeedback?.reason || validatorFeedback?.nextRequiredAction || "";
536
+ const taskContract = deriveScsTaskContract({
537
+ goal: state.goal,
538
+ taskProfile: context.taskProfile || "",
539
+ acceptanceCriteria: [],
540
+ });
471
541
 
472
542
  for (let attempt = 1; attempt <= 2; attempt += 1) {
473
543
  const rawCommittee = await callJson(
@@ -482,6 +552,7 @@ async function createScsPhase(client, config, state, context = {}, options = {})
482
552
  role: "user",
483
553
  content: [
484
554
  `Goal and evidence:\n${evidence}`,
555
+ formatHardContractForPrompt(taskContract),
485
556
  validatorFeedback
486
557
  ? `Student validator feedback that caused this ${phaseKind}:\n${compactJson(validatorFeedback, 1800)}`
487
558
  : "",
@@ -496,6 +567,13 @@ async function createScsPhase(client, config, state, context = {}, options = {})
496
567
  "SCS committee"
497
568
  );
498
569
  committee = normalizeCommitteePlan(rawCommittee, state.goal);
570
+ const deterministicIssue = deterministicPlanContractIssue(committee, taskContract);
571
+ if (deterministicIssue) {
572
+ student = normalizeDecision(deterministicIssue, "veto_plan");
573
+ lastValidatorConcern = student.reason || student.nextRequiredAction || lastValidatorConcern;
574
+ if (attempt < 2) continue;
575
+ break;
576
+ }
499
577
 
500
578
  const rawStudent = await callJson(
501
579
  client,
@@ -507,7 +585,11 @@ async function createScsPhase(client, config, state, context = {}, options = {})
507
585
  },
508
586
  {
509
587
  role: "user",
510
- content: `Goal/evidence:\n${evidence}\n\nCommittee plan:\n${compactJson(committee, 4000)}`,
588
+ content: [
589
+ `Goal/evidence:\n${evidence}`,
590
+ formatHardContractForPrompt(taskContract),
591
+ `Committee plan:\n${compactJson(committee, 4000)}`,
592
+ ].join("\n\n"),
511
593
  },
512
594
  ],
513
595
  student,
@@ -519,15 +601,19 @@ async function createScsPhase(client, config, state, context = {}, options = {})
519
601
  }
520
602
 
521
603
  if (student.decision === "veto_plan") {
604
+ const hardContractPlan = fallbackHardContractPlan(state.goal, taskContract, student.reason || lastValidatorConcern);
605
+ const hardContractCriteria = [
606
+ ...normalizeStringList(taskContract.exactOutputPaths, []).map((item) => `Exact output path is used: ${item}`),
607
+ ...normalizeStringList(taskContract.requiredTextTerms, []).map((item) => `Output contains required text: ${item}`),
608
+ ...normalizeStringList(taskContract.forbiddenTextTerms, []).map((item) => `Output omits forbidden text: ${item}`),
609
+ "Concrete file/content validation evidence is collected before finish.",
610
+ ];
522
611
  committee = normalizeCommitteePlan(
523
612
  {
524
- phase_goal: "Report SCS validator blocker instead of executing rejected target work.",
525
- plan: fallbackBlockedPlan(state.goal, student.reason || lastValidatorConcern),
526
- acceptance_criteria: [
527
- "The run does not execute target work under a rejected plan.",
528
- "The final report names the student validator concern and requested clarification or override.",
529
- ],
530
- stop_conditions: ["Any attempt to proceed with target work without an approved phase plan."],
613
+ phase_goal: "Execute the user's target work using the deterministic hard-contract fallback plan.",
614
+ plan: hardContractPlan,
615
+ acceptance_criteria: hardContractCriteria,
616
+ stop_conditions: ["A required file path is inaccessible.", "A required/forbidden text constraint cannot be satisfied."],
531
617
  },
532
618
  state.goal
533
619
  );
@@ -536,9 +622,9 @@ async function createScsPhase(client, config, state, context = {}, options = {})
536
622
  decision: "approve_plan",
537
623
  confidence: 0.9,
538
624
  reason:
539
- "Student veto remained after committee retries; approving only a blocker-reporting phase, not target execution.",
625
+ "Student veto remained after committee retries; runtime synthesized a deterministic hard-contract fallback plan that preserves exact user constraints.",
540
626
  evidence: student.evidence || [],
541
- next_required_action: "supervisor_report_validator_blocker",
627
+ next_required_action: "supervisor_execute_hard_contract_plan",
542
628
  },
543
629
  "approve_plan"
544
630
  );
@@ -756,8 +842,28 @@ export async function reviewScsFinish(client, config, state, result = "", contex
756
842
  });
757
843
  const evidenceLedger = buildScsEvidenceLedger({ state, context });
758
844
  const evidenceEvaluation = evaluateScsEvidence(taskContract, evidenceLedger);
845
+ const semanticEvaluation = evaluateScsSemanticContract(taskContract, {
846
+ commandCwd: config.commandCwd || process.cwd(),
847
+ });
759
848
  const deterministicBlocker = deterministicFinishBlocker(taskContract, evidenceLedger, evidenceEvaluation);
760
849
  const hasRealBlocker = hasScsBlockerEvidence(evidenceLedger) && finishResultClaimsBlocker(result);
850
+ if (!semanticEvaluation.ok && !hasRealBlocker) {
851
+ return normalizeDecision(
852
+ {
853
+ decision: "finish_rejected",
854
+ confidence: 0.97,
855
+ reason: `SCS semantic hard-contract gate rejected finish: ${semanticEvaluation.reason}`,
856
+ evidence: [
857
+ ...(semanticEvaluation.missingFiles || []).map((item) => `missing file: ${item}`),
858
+ ...(semanticEvaluation.missingRequiredText || []).map((item) => `missing required text: ${item}`),
859
+ ...(semanticEvaluation.presentForbiddenText || []).map((item) => `forbidden text present: ${item}`),
860
+ ],
861
+ next_required_action:
862
+ "Revise the exact output file(s) to satisfy the required and forbidden text terms, then run concrete validation commands before finishing.",
863
+ },
864
+ "finish_rejected"
865
+ );
866
+ }
761
867
  const requiresEvidence =
762
868
  taskContract.requiresExternalEvidence ||
763
869
  finishRequiresExternalEvidence(context.goal || state.goal || "", context.taskProfile || config.taskProfile || "");
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { redactSensitiveText, redactValue } from "./redaction.js";
2
4
 
3
5
  const CATEGORY_LABELS = {
@@ -62,6 +64,89 @@ function unique(items = []) {
62
64
  return [...new Set(items.filter(Boolean))];
63
65
  }
64
66
 
67
+ function uniqueLimited(items = [], limit = 16) {
68
+ return unique(items.map((item) => compact(item, 120)).filter(Boolean)).slice(0, limit);
69
+ }
70
+
71
+ function quotedTerms(text = "") {
72
+ const terms = [];
73
+ const patterns = [
74
+ /“([^”]{1,80})”/g,
75
+ /"([^"\n]{1,80})"/g,
76
+ /'([^'\n]{1,80})'/g,
77
+ /`([^`\n]{1,80})`/g,
78
+ ];
79
+ for (const pattern of patterns) {
80
+ for (const match of String(text || "").matchAll(pattern)) {
81
+ const term = String(match[1] || "").trim();
82
+ if (!term || /[\\/]/.test(term)) continue;
83
+ terms.push(term);
84
+ }
85
+ }
86
+ return terms;
87
+ }
88
+
89
+ function splitInlineTerms(text = "") {
90
+ return String(text || "")
91
+ .split(/[、,,;;]/)
92
+ .map((item) => item.replace(/[。.!!??::]/g, "").trim())
93
+ .filter((item) => item.length >= 2 && item.length <= 40 && !/[\\/]/.test(item));
94
+ }
95
+
96
+ function inferExactOutputPaths(goal = "") {
97
+ const paths = [];
98
+ const lines = String(goal || "").split(/\n+/);
99
+ const pathPattern = /(?:^|[\s"'`::])((?:~|\.{1,2}|[A-Za-z0-9_\-\u4e00-\u9fff])[\w./~\-\u4e00-\u9fff ]{0,220}\.(?:md|txt|json|ya?ml|html|css|js|ts|tsx|jsx|py|sh|csv|tex|svg|png|jpe?g|webp|mp4|mov|pdf|docx))(?:$|[\s"'`,,。;;])/gi;
100
+ for (const line of lines) {
101
+ if (!/\b(save|saved|write|written|output|create|store)\b|保存|写入|寫入|输出|輸出|创建|建立/.test(line)) continue;
102
+ for (const match of line.matchAll(pathPattern)) {
103
+ const raw = String(match[1] || "").trim();
104
+ if (!raw) continue;
105
+ paths.push(raw);
106
+ }
107
+ }
108
+ return uniqueLimited(paths, 8);
109
+ }
110
+
111
+ function inferRequiredTextTerms(goal = "") {
112
+ const terms = [];
113
+ const lines = String(goal || "").split(/\n+/);
114
+ for (const line of lines) {
115
+ const positiveSegment = String(line || "").split(
116
+ /(?:并)?确认没有|(?:并)?確認沒有|没有|沒有|\b(?:does not contain|do not contain|not contain|not include|without)\b/i
117
+ )[0];
118
+ if (
119
+ /\b(must|require|required|include|contain|contains|check|verify|grep|keyword|keywords)\b/i.test(line) ||
120
+ /必须|必須|要求|包含|检查|檢查|验证|驗證|关键词|關鍵詞|自检|自檢/.test(line)
121
+ ) {
122
+ terms.push(...quotedTerms(positiveSegment));
123
+ }
124
+ }
125
+ return uniqueLimited(terms, 24);
126
+ }
127
+
128
+ function inferForbiddenTextTerms(goal = "") {
129
+ const terms = [];
130
+ const lines = String(goal || "").split(/\n+/);
131
+ for (const line of lines) {
132
+ const text = String(line || "");
133
+ if (
134
+ /\b(?:do not|don't|dont|must not|never)\s+(?:write|include|mention|contain)\b/i.test(text) ||
135
+ /不要(?:写|寫|包含|提到)|不得(?:写|寫|包含|提到)|禁止(?:写|寫|包含|提到)|(?:确认|確認)(?:没有|沒有)/.test(text)
136
+ ) {
137
+ const tail =
138
+ text.match(/(?:不要(?:写|寫|包含|提到)|不得(?:写|寫|包含|提到)|禁止(?:写|寫|包含|提到))(.+)/)?.[1] ||
139
+ text.match(/(?:确认|確認)(?:没有|沒有)(.+)/)?.[1] ||
140
+ text.match(/\b(?:do not|don't|dont|must not|never)\s+(?:write|include|mention|contain)\s+(.+)/i)?.[1] ||
141
+ "";
142
+ terms.push(...quotedTerms(tail));
143
+ const unquotedTail = tail.replace(/“[^”]+”|"[^"\n]+"|'[^'\n]+'|`[^`\n]+`/g, "");
144
+ terms.push(...splitInlineTerms(unquotedTail).filter((item) => !/^(and|or|the|a|an|other|其他|上一集道具)$/.test(item)));
145
+ }
146
+ }
147
+ return uniqueLimited(terms, 16);
148
+ }
149
+
65
150
  function blockerFromPayload(payload = {}, source = "tool") {
66
151
  if (!payload || typeof payload !== "object") return null;
67
152
  if (!payload.blocked && payload.ok !== false && !payload.permissionAdvice) return null;
@@ -182,7 +267,7 @@ function inferRequirementCategories(goal = "", taskProfile = "", acceptanceCrite
182
267
  if (textHas(text, /\b(artifact|canvas|pdf|image|video|screenshot|cover|plot|chart|figure|docx|archive|copy to|export|generated|generate|draft)\b/) || /输出|产物|图片|视频|截图|封面|生成/.test(text)) {
183
268
  categories.add("artifact");
184
269
  }
185
- if (textHas(text, /\b(browser|chrome|chromium|cdp|devtools|playwright|selenium|web[- ]?ui|website|page|tab|composer|click|type|upload|attach|submit|form)\b/) || /浏览器|网页|页面|上传|提交|附件|资产库|按钮/.test(text)) {
270
+ if (textHas(text, /\b(browser|chrome|chromium|cdp|devtools|playwright|selenium|web[- ]?ui|website|page|tab|composer|click|type|upload|attach|submit|form)\b/) || /浏览器|网页|页面|上传|提交|附件|资产库/.test(text)) {
186
271
  categories.add("browser");
187
272
  }
188
273
  if (textHas(text, /\b(screenshot|visible|visual|see|inspect image|open image|read_image|thumbnail)\b/) || /截图|可见|缩略图/.test(text)) {
@@ -205,6 +290,10 @@ function inferRequirementCategories(goal = "", taskProfile = "", acceptanceCrite
205
290
  function inferForbiddenActions(goal = "") {
206
291
  const text = String(goal || "");
207
292
  const forbidden = [];
293
+ const isAction = (value = "") =>
294
+ /\b(use|open|click|browse|browser|upload|attach|submit|publish|deploy|run|execute|install|delete|remove|commit|push|call|api)\b/i.test(
295
+ value
296
+ ) || /浏览器|网页|打开|点击|上传|提交|发布|部署|运行|执行|安装|删除|复制|移动|提交代码|推送|调用|API/.test(value);
208
297
  const patterns = [
209
298
  { re: /\b(do not|don't|dont|never|no need to|without)\s+([^.\n;]+)/gi, prefix: "User forbids" },
210
299
  { re: /不要([^。\n;]+)/g, prefix: "User forbids" },
@@ -212,7 +301,8 @@ function inferForbiddenActions(goal = "") {
212
301
  ];
213
302
  for (const { re, prefix } of patterns) {
214
303
  for (const match of text.matchAll(re)) {
215
- forbidden.push(`${prefix}: ${compact(match[2] || match[1], 160)}`);
304
+ const value = compact(match[2] || match[1], 160);
305
+ if (isAction(value)) forbidden.push(`${prefix}: ${value}`);
216
306
  }
217
307
  }
218
308
  return unique(forbidden).slice(0, 8);
@@ -240,10 +330,78 @@ export function deriveScsTaskContract({ goal = "", taskProfile = "", acceptanceC
240
330
  requiresExternalEvidence,
241
331
  requiredEvidence,
242
332
  forbiddenActions: inferForbiddenActions(goal),
333
+ exactOutputPaths: inferExactOutputPaths(goal),
334
+ requiredTextTerms: inferRequiredTextTerms(goal),
335
+ forbiddenTextTerms: inferForbiddenTextTerms(goal),
243
336
  successCriteria: unique(acceptanceCriteria).slice(0, 10),
244
337
  };
245
338
  }
246
339
 
340
+ function resolveContractPath(commandCwd = process.cwd(), rawPath = "") {
341
+ const text = String(rawPath || "").trim();
342
+ if (!text) return "";
343
+ if (text.startsWith("~/")) return path.join(process.env.HOME || commandCwd, text.slice(2));
344
+ if (path.isAbsolute(text)) return text;
345
+ return path.resolve(commandCwd || process.cwd(), text);
346
+ }
347
+
348
+ export function evaluateScsSemanticContract(contract = {}, { commandCwd = process.cwd() } = {}) {
349
+ const exactOutputPaths = Array.isArray(contract.exactOutputPaths) ? contract.exactOutputPaths : [];
350
+ const requiredTextTerms = Array.isArray(contract.requiredTextTerms) ? contract.requiredTextTerms : [];
351
+ const forbiddenTextTerms = Array.isArray(contract.forbiddenTextTerms) ? contract.forbiddenTextTerms : [];
352
+ if (!exactOutputPaths.length && !requiredTextTerms.length && !forbiddenTextTerms.length) {
353
+ return { ok: true, checked: false, reason: "No semantic file contract was inferred." };
354
+ }
355
+ if (!exactOutputPaths.length) {
356
+ return {
357
+ ok: true,
358
+ checked: false,
359
+ reason: "Semantic text terms were inferred, but no exact output path was inferred for deterministic file inspection.",
360
+ requiredTextTerms,
361
+ forbiddenTextTerms,
362
+ };
363
+ }
364
+
365
+ const files = exactOutputPaths.map((rawPath) => {
366
+ const absolutePath = resolveContractPath(commandCwd, rawPath);
367
+ try {
368
+ const content = fs.readFileSync(absolutePath, "utf8");
369
+ return { rawPath, absolutePath, exists: true, content };
370
+ } catch {
371
+ return { rawPath, absolutePath, exists: false, content: "" };
372
+ }
373
+ });
374
+ const missingFiles = files.filter((file) => !file.exists).map((file) => file.rawPath);
375
+ const combinedContent = files.map((file) => file.content).join("\n");
376
+ const missingRequiredText = requiredTextTerms.filter((term) => !combinedContent.includes(term));
377
+ const presentForbiddenText = forbiddenTextTerms.filter((term) => combinedContent.includes(term));
378
+ const ok = missingFiles.length === 0 && missingRequiredText.length === 0 && presentForbiddenText.length === 0;
379
+ return {
380
+ ok,
381
+ checked: true,
382
+ exactOutputPaths,
383
+ requiredTextTerms,
384
+ forbiddenTextTerms,
385
+ missingFiles,
386
+ missingRequiredText,
387
+ presentForbiddenText,
388
+ inspectedFiles: files.map((file) => ({
389
+ path: file.rawPath,
390
+ exists: file.exists,
391
+ chars: file.content.length,
392
+ })),
393
+ reason: ok
394
+ ? "Exact output files satisfy inferred semantic hard constraints."
395
+ : [
396
+ missingFiles.length ? `Missing exact output files: ${missingFiles.join(", ")}` : "",
397
+ missingRequiredText.length ? `Missing required text terms: ${missingRequiredText.join(", ")}` : "",
398
+ presentForbiddenText.length ? `Forbidden text terms present: ${presentForbiddenText.join(", ")}` : "",
399
+ ]
400
+ .filter(Boolean)
401
+ .join("; "),
402
+ };
403
+ }
404
+
247
405
  function eventToEvidence(event = {}) {
248
406
  const type = String(event.type || "");
249
407
  const data = event.data && typeof event.data === "object" ? event.data : {};
@@ -415,6 +573,9 @@ export function summarizeScsContractEvidence({ contract = {}, ledger = {}, evalu
415
573
  description: item.description,
416
574
  })),
417
575
  forbiddenActions: contract.forbiddenActions || [],
576
+ exactOutputPaths: contract.exactOutputPaths || [],
577
+ requiredTextTerms: contract.requiredTextTerms || [],
578
+ forbiddenTextTerms: contract.forbiddenTextTerms || [],
418
579
  successCriteria: contract.successCriteria || [],
419
580
  },
420
581
  evidenceLedger: {
@@ -1,4 +1,7 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import { getModelPresets } from "./model-routing.js";
3
6
  import { redactSensitiveText } from "./redaction.js";
4
7
 
@@ -11,7 +14,7 @@ const BASE_ADVISORY_PROMPT = [
11
14
  "Return concise findings, commands to consider, or an implementation plan.",
12
15
  ].join(" ");
13
16
 
14
- function commandExists(command) {
17
+ export function commandExists(command) {
15
18
  try {
16
19
  if (process.platform === "win32") {
17
20
  execFileSync("where", [command], { stdio: "ignore" });
@@ -225,6 +228,11 @@ export function listAgentWrappers() {
225
228
  ];
226
229
  }
227
230
 
231
+ export function isWrapperAvailable(wrapper) {
232
+ const candidate = normalizeWrapperName(wrapper);
233
+ return Boolean(wrapperCommand(candidate, "", {}, {}) && commandExists(wrapperCommand(candidate, "", {}, {}).command));
234
+ }
235
+
228
236
  export function wrapperStatusText() {
229
237
  return listAgentWrappers()
230
238
  .map((wrapper) => `${wrapper.name}:${wrapper.available ? "available" : "missing"}`)
@@ -284,3 +292,95 @@ export async function runAgentWrapper({ wrapper, prompt }, config) {
284
292
  };
285
293
  }
286
294
  }
295
+
296
+ function codexImageArgs(prompt, imagePaths, config, preset, outputFile) {
297
+ const imageArgs = [];
298
+ for (const imagePath of imagePaths || []) {
299
+ imageArgs.push("--image", imagePath);
300
+ }
301
+ return [
302
+ "exec",
303
+ "--model",
304
+ preset.model,
305
+ "-c",
306
+ `model_reasoning_effort="${preset.reasoning}"`,
307
+ "--sandbox",
308
+ "read-only",
309
+ "--cd",
310
+ config.commandCwd,
311
+ "--skip-git-repo-check",
312
+ "--output-last-message",
313
+ outputFile,
314
+ ...imageArgs,
315
+ "--",
316
+ buildPrompt(prompt),
317
+ ];
318
+ }
319
+
320
+ export async function runCodexImageWrapper({ prompt, imagePaths = [] }, config) {
321
+ if (!commandExists("codex")) {
322
+ return { ok: false, wrapper: "codex", error: "Codex CLI is not available on PATH." };
323
+ }
324
+ if (!Array.isArray(imagePaths) || imagePaths.length === 0) {
325
+ return { ok: false, wrapper: "codex", error: "At least one image file is required for Codex image reading." };
326
+ }
327
+
328
+ const presets = getModelPresets({
329
+ wrapperModel: config.wrapperModel,
330
+ wrapperReasoning: config.wrapperReasoning,
331
+ });
332
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "aginti-codex-image-"));
333
+ const runOnce = async (preset, label) => {
334
+ const outputFile = path.join(tempDir, `${label}.txt`);
335
+ const result = await runWrapperProcess(
336
+ {
337
+ command: "codex",
338
+ args: codexImageArgs(prompt, imagePaths, config, preset, outputFile),
339
+ },
340
+ config
341
+ );
342
+ const lastMessage = await fs.readFile(outputFile, "utf8").catch(() => "");
343
+ return {
344
+ stdout: cleanOutput(lastMessage || result.stdout, 24000),
345
+ stderr: cleanOutput(result.stderr, 4000),
346
+ };
347
+ };
348
+
349
+ try {
350
+ const result = await runOnce(presets.codexPrimary, "primary");
351
+ return {
352
+ ok: true,
353
+ wrapper: "codex",
354
+ model: presets.codexPrimary.model,
355
+ reasoning: presets.codexPrimary.reasoning,
356
+ stdout: result.stdout,
357
+ stderr: result.stderr,
358
+ };
359
+ } catch (error) {
360
+ if (error?.name === "AbortError" || error?.code === "ABORT_ERR") throw error;
361
+ try {
362
+ const fallback = await runOnce(presets.codexSpare, "fallback");
363
+ return {
364
+ ok: true,
365
+ wrapper: "codex",
366
+ fallback: true,
367
+ model: presets.codexSpare.model,
368
+ reasoning: presets.codexSpare.reasoning,
369
+ stdout: fallback.stdout,
370
+ stderr: fallback.stderr,
371
+ };
372
+ } catch (fallbackError) {
373
+ if (fallbackError?.name === "AbortError" || fallbackError?.code === "ABORT_ERR") throw fallbackError;
374
+ return {
375
+ ok: false,
376
+ wrapper: "codex",
377
+ error: redactSensitiveText(fallbackError instanceof Error ? fallbackError.message : String(fallbackError)),
378
+ primaryError: redactSensitiveText(error instanceof Error ? error.message : String(error)),
379
+ stdout: cleanOutput(fallbackError.stdout, 8000),
380
+ stderr: cleanOutput(fallbackError.stderr, 4000),
381
+ };
382
+ }
383
+ } finally {
384
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
385
+ }
386
+ }
package/web.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  getProviderDefaults,
18
18
  normalizeRoutingMode,
19
19
  } from "./src/model-routing.js";
20
- import { listAgentWrappers, normalizeWrapperName } from "./src/tool-wrappers.js";
20
+ import { isWrapperAvailable, listAgentWrappers, normalizeWrapperName } from "./src/tool-wrappers.js";
21
21
  import { getDockerSandboxStatus, getSandboxLogs, runDockerPreflight } from "./src/docker-sandbox.js";
22
22
  import { normalizePackageInstallPolicy, normalizeSandboxMode } from "./src/command-policy.js";
23
23
  import { summarizeWorkspaceTools, WORKSPACE_TOOL_NAMES } from "./src/workspace-tools.js";
@@ -574,7 +574,11 @@ function normalizePreferencePayload(body = {}, current = db.getPreferences()) {
574
574
  ? Math.min(Math.max(parsedParallelScoutCount, 1), 10)
575
575
  : Number(current.parallelScoutCount) || 3,
576
576
  allowWrapperTools:
577
- typeof body.allowWrapperTools === "boolean" ? body.allowWrapperTools : Boolean(current.allowWrapperTools),
577
+ typeof body.allowWrapperTools === "boolean"
578
+ ? body.allowWrapperTools
579
+ : current.allowWrapperTools !== undefined
580
+ ? Boolean(current.allowWrapperTools)
581
+ : isWrapperAvailable(normalizeWrapperName(body.preferredWrapper || current.preferredWrapper || "codex")),
578
582
  preferredWrapper: normalizeWrapperName(body.preferredWrapper || current.preferredWrapper || "codex"),
579
583
  wrapperTimeoutMs:
580
584
  Number.isFinite(parsedWrapperTimeoutMs) && parsedWrapperTimeoutMs >= 10000