ennodia 0.2.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.
- package/README.md +76 -119
- package/docs/evidence/building-ennodia.md +66 -0
- package/docs/evidence/docs-drift.md +73 -0
- package/docs/evidence/index.md +18 -0
- package/docs/evidence/measurement.md +40 -0
- package/docs/evidence/skill-trial.md +68 -0
- package/docs/getting-started.md +55 -32
- package/docs/guides/agent-skills.md +22 -8
- package/docs/guides/budgets-and-limits.md +1 -1
- package/docs/guides/pragmatic-mode.md +122 -0
- package/docs/guides/recipes.md +56 -0
- package/docs/guides/troubleshooting.md +48 -0
- package/docs/guides/understand-results.md +41 -0
- package/docs/index.md +27 -90
- package/docs/install.md +49 -16
- package/docs/reference/controlled-english.md +2 -3
- package/docs/reference/ennodia-io.md +27 -4
- package/docs/reference/mcp-tools.md +167 -10
- package/docs/reference/supported-harnesses.md +40 -1
- package/docs/roadmap.md +25 -0
- package/examples/docs-drift/README.md +8 -0
- package/examples/docs-drift/archive/migration.md +4 -0
- package/examples/docs-drift/check.ts +7 -0
- package/examples/docs-drift/config.ts +8 -0
- package/examples/docs-drift/demo.ts +54 -0
- package/examples/docs-drift/recorded-run.json +16 -0
- package/examples/skill-trial/README.md +8 -0
- package/examples/skill-trial/check.ts +15 -0
- package/examples/skill-trial/contract.md +7 -0
- package/examples/skill-trial/recorded-trial.json +42 -0
- package/examples/skill-trial/retry.ts +4 -0
- package/package.json +5 -2
- package/skills/pragmatic/SKILL.md +40 -0
- package/src/advisor.ts +5 -0
- package/src/budget.ts +16 -10
- package/src/cli.ts +3 -0
- package/src/compare.ts +18 -12
- package/src/compositional.ts +22 -4
- package/src/core.ts +77 -47
- package/src/diagnosis.ts +44 -7
- package/src/harnesses.ts +286 -33
- package/src/history.ts +4 -1
- package/src/index.ts +3 -0
- package/src/plan-advice.ts +5 -2
- package/src/planner.ts +24 -1
- package/src/pragmatic.ts +52 -0
- package/src/process.ts +18 -0
- package/src/runs.ts +85 -10
- package/src/server.ts +47 -17
- package/src/tasks.ts +191 -25
- package/src/version.ts +1 -1
package/src/diagnosis.ts
CHANGED
|
@@ -42,23 +42,29 @@ export function diagnoseTasks(tasks: TaskView[]): TaskDiagnosis | undefined {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function likelyCause(tasks: TaskView[]): string {
|
|
45
|
+
if (tasks.every(hasQuotaError)) {
|
|
46
|
+
return "Provider usage capacity was exhausted. This attempt does not establish solution quality or a cost saving.";
|
|
47
|
+
}
|
|
48
|
+
if (tasks.every(hasAuthenticationError)) {
|
|
49
|
+
return "Agent authentication failed. This attempt did not establish how the selected model would handle the task.";
|
|
50
|
+
}
|
|
45
51
|
if (tasks.every((task) => task.timedOut)) {
|
|
46
52
|
if (tasks.some(outputChars) && tasks.some(hasRecentOutput)) {
|
|
47
|
-
return "
|
|
53
|
+
return "The task produced output near its deadline but did not return a completed result.";
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
if (tasks.some(outputChars)) {
|
|
51
|
-
return "
|
|
57
|
+
return "Ennodia stopped the task at its execution deadline after capturing output; the value of that output is not yet assessed.";
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
return "
|
|
60
|
+
return "Ennodia stopped the task at its execution deadline without captured output. This does not establish whether the model was investigating, blocked, or unable to solve the task.";
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
if (tasks.some((task) => task.drainTimedOut)) {
|
|
58
64
|
return "Output drain timed out; captured output may be incomplete.";
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
if (tasks.some((task) => task.exitCode !== undefined && task.exitCode !== 0)) {
|
|
67
|
+
if (tasks.some((task) => !task.timedOut && !task.cancelRequested && task.exitCode !== undefined && task.exitCode !== null && task.exitCode !== 0)) {
|
|
62
68
|
return "Provider command error or bad configuration.";
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -75,17 +81,27 @@ function suggestions(
|
|
|
75
81
|
): string[] {
|
|
76
82
|
const result = new Set<string>();
|
|
77
83
|
|
|
84
|
+
if (tasks.some(hasQuotaError)) {
|
|
85
|
+
result.add("Preserve partial findings. Check capacity through the provider's supported interface before retrying; do not repeat calls against the exhausted account.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (tasks.some(hasAuthenticationError)) {
|
|
89
|
+
result.add("Sign in through the affected agent's supported CLI, then retry the same task.");
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
if (tasks.some((task) => task.timedOut)) {
|
|
79
|
-
result.add("
|
|
93
|
+
result.add("Review captured findings and partial changes before retrying. Check the assigned scope, deadline, tool access, and permissions; continue useful work with an adequate budget instead of repeating the investigation.");
|
|
80
94
|
}
|
|
81
95
|
|
|
82
96
|
if (hasPartialOutput) {
|
|
83
97
|
result.add("Inspect partial output and events with ennodia_get_task.");
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
if (tasks.some((task) => task.exitCode !== undefined && task.exitCode !== 0)) {
|
|
100
|
+
if (tasks.some((task) => !task.timedOut && !task.cancelRequested && task.exitCode !== undefined && task.exitCode !== null && task.exitCode !== 0)) {
|
|
87
101
|
result.add("Inspect stderr and task events with ennodia_get_task.");
|
|
88
|
-
|
|
102
|
+
if (!tasks.every(task => hasAuthenticationError(task) || hasQuotaError(task))) {
|
|
103
|
+
result.add("Check the requested model and command configuration before retrying.");
|
|
104
|
+
}
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
if (tasks.some((task) => task.drainTimedOut)) {
|
|
@@ -99,6 +115,27 @@ function suggestions(
|
|
|
99
115
|
return [...result];
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
/** Only native error envelopes count; quoted tool output is not a quota signal. */
|
|
119
|
+
export function hasCodexQuotaError(stdout: string): boolean {
|
|
120
|
+
for (const line of stdout.split("\n")) {
|
|
121
|
+
let event;
|
|
122
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
123
|
+
const message = event?.type === "error" ? event.message
|
|
124
|
+
: event?.type === "turn.failed" ? event.error?.message : undefined;
|
|
125
|
+
if (typeof message === "string" && /^You've hit your usage limit\b/i.test(message)) return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasQuotaError(task: TaskView): boolean {
|
|
131
|
+
return task.harnessId === "codex" && (hasCodexQuotaError(task.stdout) || hasCodexQuotaError(task.stderr));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasAuthenticationError(task: TaskView): boolean {
|
|
135
|
+
return !task.timedOut && /^(?:Error: )?(?:Failed to authenticate:|OAuth session expired|Authentication failed:|Not logged in\b)/im
|
|
136
|
+
.test(`${task.stdout}\n${task.stderr}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
102
139
|
function summaryLine(task: TaskView): string {
|
|
103
140
|
const status = task.timedOut ? "timed out" : task.status;
|
|
104
141
|
const facts = [
|
package/src/harnesses.ts
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { signalOwnedProcess } from "./process";
|
|
2
3
|
import { type Skill } from "./skills";
|
|
3
4
|
|
|
4
5
|
export type HarnessKind = "cli" | "app";
|
|
5
6
|
|
|
7
|
+
export const DEFAULT_TASK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
export const REASONING_EFFORTS = [
|
|
10
|
+
"low",
|
|
11
|
+
"medium",
|
|
12
|
+
"high",
|
|
13
|
+
"xhigh",
|
|
14
|
+
"max",
|
|
15
|
+
"ultra",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
|
19
|
+
|
|
6
20
|
export type HarnessRunInput = {
|
|
7
21
|
prompt: string;
|
|
8
22
|
cwd?: string;
|
|
9
23
|
model?: string;
|
|
24
|
+
reasoningEffort?: ReasoningEffort;
|
|
25
|
+
nativeSubagents?: "disabled";
|
|
26
|
+
nativeSandbox?: "read-only" | "workspace-write";
|
|
27
|
+
/** Request native session persistence when the selected adapter supports it. */
|
|
28
|
+
persistSession?: boolean;
|
|
29
|
+
/** Manager-resolved native session ID to resume for a continuation. */
|
|
30
|
+
nativeSessionId?: string;
|
|
31
|
+
/** TaskManager-owned task ID for continuation; adapters do not consume it. */
|
|
32
|
+
continueTaskId?: string;
|
|
10
33
|
timeoutMs?: number;
|
|
11
34
|
skills?: Skill[];
|
|
12
35
|
/** Run the task against an ephemeral isolated copy of cwd instead of cwd
|
|
@@ -24,6 +47,12 @@ export type HarnessUsage = {
|
|
|
24
47
|
/** Best-effort, adapter-reported token count. Not guaranteed available or
|
|
25
48
|
* billing-accurate; parsed from each CLI's own text output where possible. */
|
|
26
49
|
tokensUsed?: number;
|
|
50
|
+
/** Native input-token count when the adapter reports a structured breakdown. */
|
|
51
|
+
inputTokens?: number;
|
|
52
|
+
/** Native cached-input count; this is a subset of inputTokens. */
|
|
53
|
+
cachedInputTokens?: number;
|
|
54
|
+
/** Native output-token count when the adapter reports a structured breakdown. */
|
|
55
|
+
outputTokens?: number;
|
|
27
56
|
};
|
|
28
57
|
|
|
29
58
|
export type CommandSpec = {
|
|
@@ -43,11 +72,46 @@ export type HarnessAdapter = {
|
|
|
43
72
|
versionArgs?: string[];
|
|
44
73
|
capabilities: string[];
|
|
45
74
|
notes?: string[];
|
|
75
|
+
/** Input preparation guidance, not a guarantee of model or tool access. */
|
|
76
|
+
inputGuidance?: string[];
|
|
77
|
+
/** Whether the adapter can receive an explicit reasoning effort setting. */
|
|
78
|
+
supportsReasoningEffort?: boolean;
|
|
79
|
+
supportsNativeSubagentControl?: boolean;
|
|
80
|
+
supportsNativeSandbox?: boolean;
|
|
81
|
+
/** Whether the adapter supports manager-owned native session continuation. */
|
|
82
|
+
supportsSessionContinuation?: boolean;
|
|
46
83
|
buildCommand?: (commandPath: string, input: HarnessRunInput) => CommandSpec;
|
|
47
84
|
/** Best-effort usage extraction from a finished task's captured output. */
|
|
48
85
|
extractUsage?: (stdout: string, stderr: string) => HarnessUsage | undefined;
|
|
86
|
+
/** Extract a native session ID from captured stdout as it arrives. */
|
|
87
|
+
extractSessionId?: (stdout: string) => string | undefined;
|
|
88
|
+
/** Some public CLIs report a semantic failure with exit code zero. */
|
|
89
|
+
failureReason?: (stdout: string, stderr: string) => string | undefined;
|
|
49
90
|
};
|
|
50
91
|
|
|
92
|
+
export function assertNativeSandboxSupported(adapter: HarnessAdapter, setting?: "read-only" | "workspace-write"): void {
|
|
93
|
+
if (setting === undefined) return;
|
|
94
|
+
if (setting !== "read-only" && setting !== "workspace-write") throw new Error("Unsupported nativeSandbox setting.");
|
|
95
|
+
if (!adapter.supportsNativeSandbox) throw new Error(`${adapter.name} (${adapter.id}) does not support nativeSandbox control.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function assertNativeSubagentsSupported(adapter: HarnessAdapter, setting?: "disabled"): void {
|
|
99
|
+
if (setting !== undefined && !adapter.supportsNativeSubagentControl) {
|
|
100
|
+
throw new Error(`${adapter.name} (${adapter.id}) does not support nativeSubagents control.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function assertReasoningEffortSupported(
|
|
105
|
+
adapter: Pick<HarnessAdapter, "id" | "name" | "supportsReasoningEffort">,
|
|
106
|
+
reasoningEffort?: ReasoningEffort,
|
|
107
|
+
): void {
|
|
108
|
+
if (reasoningEffort !== undefined && !adapter.supportsReasoningEffort) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${adapter.name} (${adapter.id}) does not support explicit reasoningEffort.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
51
115
|
export type HarnessDiscovery = {
|
|
52
116
|
id: string;
|
|
53
117
|
name: string;
|
|
@@ -59,6 +123,7 @@ export type HarnessDiscovery = {
|
|
|
59
123
|
version?: string;
|
|
60
124
|
capabilities: string[];
|
|
61
125
|
notes: string[];
|
|
126
|
+
inputGuidance?: string[];
|
|
62
127
|
};
|
|
63
128
|
|
|
64
129
|
export type DiscoverHarnessesOptions = {
|
|
@@ -75,12 +140,36 @@ type CaptureResult = {
|
|
|
75
140
|
|
|
76
141
|
const DEFAULT_VERSION_TIMEOUT_MS = 2_500;
|
|
77
142
|
const DEFAULT_DISCOVERY_CACHE_MS = 30_000;
|
|
143
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
78
144
|
|
|
79
145
|
let cachedDiscovery:
|
|
80
146
|
| { createdAtMs: number; harnesses: HarnessDiscovery[] }
|
|
81
147
|
| undefined;
|
|
82
148
|
let inFlightDiscovery: Promise<HarnessDiscovery[]> | undefined;
|
|
83
149
|
|
|
150
|
+
export const MULTIMODAL_INPUT_GUIDANCE = [
|
|
151
|
+
"Ennodia passes text prompts and local file paths, not media attachments. Native input depends on the selected harness, model, file tools, permissions, and format. Missing guidance means unverified access.",
|
|
152
|
+
"When native listening or viewing is required, first load one small sample with native file tools. Report the file, inspected range, tool, and content-specific evidence, or the exact blocking error. A transcript, DSP score, or extracted frame is not equivalent evidence.",
|
|
153
|
+
"For media comparisons, use matching excerpts and an original reference. Separate cleanup quality from voice or image fidelity. Report conversions and limitations; a successful process exit does not establish successful media inspection.",
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
export function hasMultimodalInput(prompt: string): boolean {
|
|
157
|
+
return /\b(audio|video|image|multimodal|listen|listening|sound|speech|voice|recordings?|footage)\b|\.(mp3|wav|flac|m4a|aac|opus|ogg|ogv|mp4|mov|webm|png|jpe?g|webp)\b/i.test(prompt);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Keep preparation instructions out of unrelated work and internal text judges. */
|
|
161
|
+
export function withInputGuidance(
|
|
162
|
+
prompt: string,
|
|
163
|
+
adapter?: Pick<HarnessAdapter, "inputGuidance">,
|
|
164
|
+
): string {
|
|
165
|
+
if (!hasMultimodalInput(prompt) || /^ENNODIA_(?:COMPARE|PLAN_ADVISOR)/.test(prompt)) {
|
|
166
|
+
return prompt;
|
|
167
|
+
}
|
|
168
|
+
return `${prompt}\n\nEnnodia media input guidance (applies when native media inspection is required):\n${
|
|
169
|
+
(adapter?.inputGuidance ?? MULTIMODAL_INPUT_GUIDANCE).map((note) => `- ${note}`).join("\n")
|
|
170
|
+
}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
84
173
|
export const harnessAdapters: HarnessAdapter[] = [
|
|
85
174
|
{
|
|
86
175
|
id: "claude-code",
|
|
@@ -102,7 +191,7 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
102
191
|
args.push("--model", input.model);
|
|
103
192
|
}
|
|
104
193
|
|
|
105
|
-
args.push(input.prompt);
|
|
194
|
+
args.push("--", input.prompt);
|
|
106
195
|
return { command: commandPath, args, cwd: input.cwd };
|
|
107
196
|
},
|
|
108
197
|
},
|
|
@@ -114,17 +203,26 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
114
203
|
versionArgs: ["--version"],
|
|
115
204
|
capabilities: ["reasoning", "code", "agents", "mcp", "non-interactive-cli"],
|
|
116
205
|
notes: ["Defaults to read-only sandboxing for Ennodia-launched tasks."],
|
|
206
|
+
supportsReasoningEffort: true,
|
|
207
|
+
supportsSessionContinuation: true,
|
|
208
|
+
supportsNativeSubagentControl: true,
|
|
209
|
+
supportsNativeSandbox: true,
|
|
117
210
|
buildCommand: (commandPath, input) => {
|
|
211
|
+
const resuming = Boolean(input.nativeSessionId);
|
|
212
|
+
const persistent = Boolean(input.persistSession || resuming);
|
|
118
213
|
const args = [
|
|
119
214
|
"exec",
|
|
120
215
|
"--color",
|
|
121
216
|
"never",
|
|
122
217
|
"--sandbox",
|
|
123
|
-
"read-only",
|
|
218
|
+
input.nativeSandbox ?? "read-only",
|
|
124
219
|
"--skip-git-repo-check",
|
|
125
|
-
"--ephemeral",
|
|
126
220
|
];
|
|
127
221
|
|
|
222
|
+
if (!persistent) {
|
|
223
|
+
args.push("--ephemeral");
|
|
224
|
+
}
|
|
225
|
+
|
|
128
226
|
if (input.cwd) {
|
|
129
227
|
args.push("-C", input.cwd);
|
|
130
228
|
}
|
|
@@ -133,15 +231,36 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
133
231
|
args.push("--model", input.model);
|
|
134
232
|
}
|
|
135
233
|
|
|
234
|
+
if (input.reasoningEffort) {
|
|
235
|
+
args.push("-c", `model_reasoning_effort="${input.reasoningEffort}"`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (input.nativeSubagents === "disabled") args.push("--disable", "multi_agent", "--disable", "multi_agent_v2");
|
|
239
|
+
|
|
240
|
+
if (resuming) {
|
|
241
|
+
args.push("resume", input.nativeSessionId!, "--json");
|
|
242
|
+
} else if (persistent) {
|
|
243
|
+
args.push("--json");
|
|
244
|
+
}
|
|
245
|
+
|
|
136
246
|
if (input.finalMessagePath) {
|
|
137
247
|
args.push("-o", input.finalMessagePath);
|
|
138
248
|
}
|
|
139
249
|
|
|
140
|
-
args.push(input.prompt);
|
|
250
|
+
args.push("--", input.prompt);
|
|
141
251
|
return { command: commandPath, args, cwd: input.cwd };
|
|
142
252
|
},
|
|
143
|
-
|
|
144
|
-
|
|
253
|
+
extractSessionId: extractCodexSessionId,
|
|
254
|
+
extractUsage: (stdout, stderr) => {
|
|
255
|
+
const jsonUsage = extractCodexJsonUsage(stdout);
|
|
256
|
+
if (jsonUsage) {
|
|
257
|
+
return jsonUsage;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (/"type"\s*:\s*"turn\.(?:started|completed|failed)"/.test(stdout)) return undefined;
|
|
261
|
+
|
|
262
|
+
const summary = /(?:^|\n)tokens used[ \t]*\r?\n[ \t]*([\d,]+)\s*$/i;
|
|
263
|
+
const match = summary.exec(stderr) ?? summary.exec(stdout);
|
|
145
264
|
if (!match) {
|
|
146
265
|
return undefined;
|
|
147
266
|
}
|
|
@@ -169,7 +288,7 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
169
288
|
args.push("--model", input.model);
|
|
170
289
|
}
|
|
171
290
|
|
|
172
|
-
args.push(input.prompt);
|
|
291
|
+
args.push("--", input.prompt);
|
|
173
292
|
return { command: commandPath, args, cwd: input.cwd };
|
|
174
293
|
},
|
|
175
294
|
},
|
|
@@ -201,7 +320,7 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
201
320
|
args.push("--model", input.model);
|
|
202
321
|
}
|
|
203
322
|
|
|
204
|
-
args.push(input.prompt);
|
|
323
|
+
args.push("--", input.prompt);
|
|
205
324
|
return { command: commandPath, args, cwd: input.cwd };
|
|
206
325
|
},
|
|
207
326
|
},
|
|
@@ -256,7 +375,7 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
256
375
|
args.push("--model", input.model);
|
|
257
376
|
}
|
|
258
377
|
|
|
259
|
-
args.push(input.prompt);
|
|
378
|
+
args.push("--", input.prompt);
|
|
260
379
|
return { command: commandPath, args, cwd: input.cwd };
|
|
261
380
|
},
|
|
262
381
|
},
|
|
@@ -281,7 +400,7 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
281
400
|
args.push("--model", input.model);
|
|
282
401
|
}
|
|
283
402
|
|
|
284
|
-
args.push(input.prompt);
|
|
403
|
+
args.push("--", input.prompt);
|
|
285
404
|
return { command: commandPath, args, cwd: input.cwd };
|
|
286
405
|
},
|
|
287
406
|
},
|
|
@@ -296,11 +415,22 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
296
415
|
"/Applications/Antigravity IDE.app",
|
|
297
416
|
],
|
|
298
417
|
capabilities: ["ide", "browser-automation", "agents", "non-interactive-cli"],
|
|
418
|
+
inputGuidance: [
|
|
419
|
+
...MULTIMODAL_INPUT_GUIDANCE,
|
|
420
|
+
"Antigravity headless input is text-only, including stream-json content blocks. Put local paths in the prompt and use the worker's native view_file tool when available; do not send Gemini API attachment blocks to agy. https://antigravity.google/docs/cli/headless/",
|
|
421
|
+
"Observed 2026-09-10 with agy 1.2.0 and gemini-3.8-flash-medium: an eight-second MP3 loaded through view_file and returned spoken words absent from the prompt. Start with a short MP3 probe for this route, then compare matched samples after confirming native access.",
|
|
422
|
+
"In that same case, FLAC was rejected as unsupported MIME audio/x-flac; four WAV samples timed out without output. These are scoped observations, not a universal FLAC/WAV support rule. CLI release notes list broader audio support: https://antigravity.google/changelog",
|
|
423
|
+
"Use native file tools first. Headless command permission denials cannot be approved interactively; report the blocked operation and keep normal permission settings. Do not keep guessing formats or repeat an unchanged failed request.",
|
|
424
|
+
],
|
|
299
425
|
notes: [
|
|
300
426
|
"Runs through the supported `agy` CLI surface.",
|
|
301
427
|
"Defaults to Antigravity sandbox mode for Ennodia-launched tasks.",
|
|
302
428
|
"Passes the prompt as the value of Antigravity's non-interactive `--print` option.",
|
|
303
429
|
],
|
|
430
|
+
failureReason: (stdout, stderr) => !stdout.trim() &&
|
|
431
|
+
/^jetski: no output produced\b/m.test(stderr)
|
|
432
|
+
? "Antigravity returned no answer. Check the required tool access in its normal permission settings."
|
|
433
|
+
: undefined,
|
|
304
434
|
buildCommand: (commandPath, input) => {
|
|
305
435
|
const args = [
|
|
306
436
|
"--sandbox",
|
|
@@ -323,6 +453,110 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
323
453
|
},
|
|
324
454
|
];
|
|
325
455
|
|
|
456
|
+
function extractCodexSessionId(stdout: string): string | undefined {
|
|
457
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
458
|
+
const event = parseJsonObject(line);
|
|
459
|
+
if (event?.type !== "thread.started") {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const threadId = event.thread_id;
|
|
464
|
+
if (typeof threadId === "string" && UUID_PATTERN.test(threadId)) {
|
|
465
|
+
return threadId;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function extractCodexJsonUsage(stdout: string): HarnessUsage | undefined {
|
|
473
|
+
let inputTokens = 0;
|
|
474
|
+
let outputTokens = 0;
|
|
475
|
+
let cachedInputTokens = 0;
|
|
476
|
+
let cachedInputKnown = true;
|
|
477
|
+
let found = false;
|
|
478
|
+
let malformed = false;
|
|
479
|
+
let pendingTurn = false;
|
|
480
|
+
|
|
481
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
482
|
+
const event = parseJsonObject(line);
|
|
483
|
+
if (event?.type === "turn.started" || event?.type === "turn.failed") pendingTurn = true;
|
|
484
|
+
if (event?.type !== "turn.completed") {
|
|
485
|
+
if (
|
|
486
|
+
!event &&
|
|
487
|
+
/^\s*\{/.test(line) &&
|
|
488
|
+
/"type"\s*:\s*"turn\.completed"/.test(line)
|
|
489
|
+
) {
|
|
490
|
+
malformed = true;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!isRecord(event.usage)) {
|
|
497
|
+
malformed = true;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const input = event.usage.input_tokens;
|
|
502
|
+
const output = event.usage.output_tokens;
|
|
503
|
+
if (!isTokenCount(input) || !isTokenCount(output)) {
|
|
504
|
+
malformed = true;
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
found = true;
|
|
509
|
+
pendingTurn = false;
|
|
510
|
+
inputTokens += input;
|
|
511
|
+
outputTokens += output;
|
|
512
|
+
const cached = event.usage.cached_input_tokens;
|
|
513
|
+
if (cached === undefined) {
|
|
514
|
+
cachedInputKnown = false;
|
|
515
|
+
} else if (!isTokenCount(cached) || cached > input) {
|
|
516
|
+
malformed = true;
|
|
517
|
+
break;
|
|
518
|
+
} else {
|
|
519
|
+
cachedInputTokens += cached;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (
|
|
524
|
+
malformed ||
|
|
525
|
+
pendingTurn ||
|
|
526
|
+
!found ||
|
|
527
|
+
!isTokenCount(inputTokens) ||
|
|
528
|
+
!isTokenCount(outputTokens) ||
|
|
529
|
+
!isTokenCount(inputTokens + outputTokens) ||
|
|
530
|
+
(cachedInputKnown && !isTokenCount(cachedInputTokens))
|
|
531
|
+
) {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
tokensUsed: inputTokens + outputTokens,
|
|
537
|
+
inputTokens,
|
|
538
|
+
...(cachedInputKnown ? { cachedInputTokens } : {}),
|
|
539
|
+
outputTokens,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function parseJsonObject(line: string): Record<string, unknown> | undefined {
|
|
544
|
+
try {
|
|
545
|
+
const value: unknown = JSON.parse(line);
|
|
546
|
+
return isRecord(value) ? value : undefined;
|
|
547
|
+
} catch {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
553
|
+
return typeof value === "object" && value !== null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isTokenCount(value: unknown): value is number {
|
|
557
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
326
560
|
export async function discoverHarnesses(
|
|
327
561
|
options: DiscoverHarnessesOptions = {},
|
|
328
562
|
): Promise<HarnessDiscovery[]> {
|
|
@@ -367,7 +601,15 @@ async function discoverHarness(
|
|
|
367
601
|
.find((match): match is string => Boolean(match));
|
|
368
602
|
|
|
369
603
|
const appPath = adapter.appPaths?.find((path) => existsSync(path));
|
|
370
|
-
|
|
604
|
+
let version: string | undefined;
|
|
605
|
+
const notes = [...(adapter.notes ?? [])];
|
|
606
|
+
if (commandPath) {
|
|
607
|
+
try {
|
|
608
|
+
version = await readVersion(commandPath, adapter);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
notes.push(`Version check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
371
613
|
const available = Boolean(commandPath || appPath);
|
|
372
614
|
const runnable = Boolean(commandPath && adapter.buildCommand);
|
|
373
615
|
|
|
@@ -381,7 +623,8 @@ async function discoverHarness(
|
|
|
381
623
|
appPath,
|
|
382
624
|
version,
|
|
383
625
|
capabilities: adapter.capabilities,
|
|
384
|
-
notes
|
|
626
|
+
notes,
|
|
627
|
+
...(adapter.inputGuidance ? { inputGuidance: [...adapter.inputGuidance] } : {}),
|
|
385
628
|
};
|
|
386
629
|
}
|
|
387
630
|
|
|
@@ -396,6 +639,8 @@ async function readVersion(
|
|
|
396
639
|
const result = await capture(commandPath, adapter.versionArgs, {
|
|
397
640
|
timeoutMs: DEFAULT_VERSION_TIMEOUT_MS,
|
|
398
641
|
});
|
|
642
|
+
if (result.timedOut) throw new Error("Version probe timed out.");
|
|
643
|
+
if (result.exitCode !== 0) throw new Error(`Version probe exited with code ${result.exitCode}.`);
|
|
399
644
|
|
|
400
645
|
const text = `${result.stdout}\n${result.stderr}`.trim();
|
|
401
646
|
return text.split(/\r?\n/).find((line) => line.trim())?.trim();
|
|
@@ -406,41 +651,49 @@ async function capture(
|
|
|
406
651
|
args: string[],
|
|
407
652
|
options: { timeoutMs: number },
|
|
408
653
|
): Promise<CaptureResult> {
|
|
409
|
-
const
|
|
654
|
+
const child = Bun.spawn({
|
|
410
655
|
cmd: [command, ...args],
|
|
411
656
|
stdout: "pipe",
|
|
412
657
|
stderr: "pipe",
|
|
658
|
+
detached: process.platform !== "win32",
|
|
413
659
|
});
|
|
414
660
|
|
|
415
661
|
let timedOut = false;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
662
|
+
let timeout: Timer | undefined;
|
|
663
|
+
const readers = [child.stdout.getReader(), child.stderr.getReader()];
|
|
664
|
+
const output = ["", ""];
|
|
665
|
+
const drained = Promise.all(readers.map(async (reader, index) => {
|
|
666
|
+
const decoder = new TextDecoder();
|
|
667
|
+
try {
|
|
668
|
+
for (;;) {
|
|
669
|
+
const { done, value } = await reader.read();
|
|
670
|
+
if (done) break;
|
|
671
|
+
output[index] = (output[index] + decoder.decode(value, { stream: true })).slice(0, 8_000);
|
|
672
|
+
}
|
|
673
|
+
output[index] += decoder.decode();
|
|
674
|
+
} catch { /* cancellation closes a stalled pipe */ }
|
|
675
|
+
finally { reader.releaseLock(); }
|
|
676
|
+
}));
|
|
420
677
|
|
|
421
678
|
try {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
679
|
+
const exitCode = await Promise.race([
|
|
680
|
+
Promise.all([drained, child.exited]).then(([, code]) => code),
|
|
681
|
+
new Promise<null>((resolve) => {
|
|
682
|
+
timeout = setTimeout(() => {
|
|
683
|
+
timedOut = true;
|
|
684
|
+
signalOwnedProcess(child, "SIGKILL");
|
|
685
|
+
for (const reader of readers) void reader.cancel().catch(() => undefined);
|
|
686
|
+
resolve(null);
|
|
687
|
+
}, options.timeoutMs);
|
|
688
|
+
}),
|
|
426
689
|
]);
|
|
427
|
-
|
|
428
|
-
return { stdout, stderr, exitCode, timedOut };
|
|
690
|
+
return { stdout: output[0], stderr: output[1], exitCode, timedOut };
|
|
429
691
|
} finally {
|
|
430
692
|
clearTimeout(timeout);
|
|
693
|
+
if (!timedOut) signalOwnedProcess(child, "SIGKILL");
|
|
431
694
|
}
|
|
432
695
|
}
|
|
433
696
|
|
|
434
|
-
async function streamToText(
|
|
435
|
-
stream: ReadableStream<Uint8Array> | null,
|
|
436
|
-
): Promise<string> {
|
|
437
|
-
if (!stream) {
|
|
438
|
-
return "";
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return await new Response(stream).text();
|
|
442
|
-
}
|
|
443
|
-
|
|
444
697
|
function toGoDuration(timeoutMs = 5 * 60 * 1000): string {
|
|
445
698
|
const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
446
699
|
return `${seconds}s`;
|
package/src/history.ts
CHANGED
|
@@ -60,10 +60,13 @@ export class FileHistorySink implements HistorySink {
|
|
|
60
60
|
|
|
61
61
|
async recordRun(snapshot: RunHistorySnapshot): Promise<void> {
|
|
62
62
|
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
63
|
+
// Frame each append with a leading newline so a previous truncated record
|
|
64
|
+
// cannot swallow the next receipt. Keep the delimiter and record in one
|
|
65
|
+
// append; never read/truncate the shared file to repair it.
|
|
63
66
|
// Append-only hot path: an interrupted write can lose at most this
|
|
64
67
|
// snapshot, and concurrent writers (MCP and IO processes share the
|
|
65
68
|
// default directory) cannot clobber each other's lines.
|
|
66
|
-
await appendFile(this.path,
|
|
69
|
+
await appendFile(this.path, `\n${serialize(snapshot)}\n`, { mode: 0o600 });
|
|
67
70
|
await this.compactIfNeeded();
|
|
68
71
|
}
|
|
69
72
|
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { pragmaticSchema, type PragmaticOptions } from "./pragmatic";
|
|
1
2
|
export {
|
|
2
3
|
createEnnodiaServer,
|
|
3
4
|
shutdownEnnodia,
|
|
@@ -99,12 +100,14 @@ export {
|
|
|
99
100
|
discoverHarnesses,
|
|
100
101
|
findHarnessAdapter,
|
|
101
102
|
harnessAdapters,
|
|
103
|
+
REASONING_EFFORTS,
|
|
102
104
|
type CommandSpec,
|
|
103
105
|
type DiscoverHarnessesOptions,
|
|
104
106
|
type HarnessAdapter,
|
|
105
107
|
type HarnessDiscovery,
|
|
106
108
|
type HarnessKind,
|
|
107
109
|
type HarnessRunInput,
|
|
110
|
+
type ReasoningEffort,
|
|
108
111
|
} from "./harnesses";
|
|
109
112
|
export { planRoute, renderPlanMermaid, type RoutePlan } from "./planner";
|
|
110
113
|
export {
|
package/src/plan-advice.ts
CHANGED
|
@@ -97,6 +97,7 @@ export type PlanAdviceManagerStartInput = {
|
|
|
97
97
|
advisorModel?: string;
|
|
98
98
|
timeoutMs?: number;
|
|
99
99
|
budget?: BudgetLimits;
|
|
100
|
+
findHarnessAdapter?: (id: string) => HarnessAdapter | undefined;
|
|
100
101
|
};
|
|
101
102
|
|
|
102
103
|
export type PlanAdviceRevalidationContext = {
|
|
@@ -117,6 +118,7 @@ type InternalPlanAdvice = {
|
|
|
117
118
|
advisorModel?: string;
|
|
118
119
|
advisorTimeoutMs: number;
|
|
119
120
|
budgetLimits?: BudgetLimits;
|
|
121
|
+
findHarnessAdapter?: PlanAdviceManagerStartInput["findHarnessAdapter"];
|
|
120
122
|
advisorBudget: BudgetCheck;
|
|
121
123
|
executionBudget?: BudgetCheck;
|
|
122
124
|
proposal?: AdvisorPlanProposal;
|
|
@@ -167,7 +169,7 @@ export class PlanAdviceManager {
|
|
|
167
169
|
harnessId: input.advisor.adapter.id,
|
|
168
170
|
}],
|
|
169
171
|
comparePlanned: false,
|
|
170
|
-
}),
|
|
172
|
+
}, () => input.advisor.adapter),
|
|
171
173
|
input.budget,
|
|
172
174
|
);
|
|
173
175
|
assertBudgetWithinLimits(advisorBudget);
|
|
@@ -208,6 +210,7 @@ export class PlanAdviceManager {
|
|
|
208
210
|
advisorTimeoutMs: advisorTask.timeoutMs ??
|
|
209
211
|
input.timeoutMs ?? DEFAULT_ADVISOR_TIMEOUT_MS,
|
|
210
212
|
budgetLimits: input.budget ? { ...input.budget } : undefined,
|
|
213
|
+
findHarnessAdapter: input.findHarnessAdapter,
|
|
211
214
|
advisorBudget,
|
|
212
215
|
issues: [],
|
|
213
216
|
createdAtMs: now,
|
|
@@ -386,7 +389,7 @@ export class PlanAdviceManager {
|
|
|
386
389
|
harnessId: slice.harnessId,
|
|
387
390
|
})),
|
|
388
391
|
comparePlanned: false,
|
|
389
|
-
}),
|
|
392
|
+
}, record.findHarnessAdapter),
|
|
390
393
|
record.budgetLimits,
|
|
391
394
|
);
|
|
392
395
|
record.executionBudget = executionBudget;
|