@mrclrchtr/supi-consulting 11.0.1 → 11.2.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 +46 -6
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +41 -1
- package/package.json +2 -2
- package/scripts/live-probe.ts +6 -3
- package/src/agents/antigravity/adapter.ts +6 -2
- package/src/agents/antigravity/isolated-home.ts +1 -7
- package/src/agents/antigravity/process/event-values.ts +39 -13
- package/src/agents/antigravity/process/events.ts +154 -131
- package/src/agents/antigravity/process/hooks.ts +9 -1
- package/src/agents/antigravity/process/permission-diagnostics.ts +260 -0
- package/src/agents/antigravity/process/protocol.ts +57 -1
- package/src/agents/antigravity/process/runner.ts +50 -19
- package/src/agents/errors.ts +190 -10
- package/src/agents/types.ts +2 -0
- package/src/config.ts +60 -4
- package/src/runtime.ts +3 -1
- package/src/settings.ts +21 -2
- package/src/tool/consulting_run/execute.ts +13 -0
- package/src/tool/consulting_run/guidance.ts +1 -1
- package/src/tool/consulting_run/register.ts +3 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type ConsultingDeniedOperation,
|
|
5
|
+
type ConsultingFileResource,
|
|
6
|
+
MAX_FAILURE_RESOURCE_PATH_CHARS,
|
|
7
|
+
MAX_FAILURE_RESOURCES,
|
|
8
|
+
} from "../../errors.ts";
|
|
9
|
+
import {
|
|
10
|
+
eventStatus,
|
|
11
|
+
eventType,
|
|
12
|
+
extractWorkspacePath,
|
|
13
|
+
isErrorStatus,
|
|
14
|
+
isRecord,
|
|
15
|
+
isSuccessStatus,
|
|
16
|
+
normalizeToolName,
|
|
17
|
+
safeString,
|
|
18
|
+
structuredWorkspacePath,
|
|
19
|
+
toolInput,
|
|
20
|
+
} from "./event-values.ts";
|
|
21
|
+
|
|
22
|
+
/** Reduce known tool names to fixed diagnostic categories, without retaining arbitrary names. */
|
|
23
|
+
export function classifyDeniedOperation(name: string | undefined): ConsultingDeniedOperation {
|
|
24
|
+
switch (name) {
|
|
25
|
+
case "command":
|
|
26
|
+
case "run_command":
|
|
27
|
+
case "run_terminal_command":
|
|
28
|
+
return "command";
|
|
29
|
+
case "read_file":
|
|
30
|
+
case "view_file":
|
|
31
|
+
case "view_file_outline":
|
|
32
|
+
case "view_file_item":
|
|
33
|
+
case "list_dir":
|
|
34
|
+
case "list_files":
|
|
35
|
+
case "find_by_name":
|
|
36
|
+
case "grep_search":
|
|
37
|
+
case "code_search":
|
|
38
|
+
return "file-read";
|
|
39
|
+
case "write_file":
|
|
40
|
+
case "write_to_file":
|
|
41
|
+
case "replace_file_content":
|
|
42
|
+
case "multi_replace_file_content":
|
|
43
|
+
case "apply_patch":
|
|
44
|
+
return "file-write";
|
|
45
|
+
case "search_web":
|
|
46
|
+
case "read_url_content":
|
|
47
|
+
case "read_url":
|
|
48
|
+
case "web_search":
|
|
49
|
+
return "web-read";
|
|
50
|
+
case "execute_url":
|
|
51
|
+
case "browser_subagent":
|
|
52
|
+
return "browser";
|
|
53
|
+
default:
|
|
54
|
+
return name === "mcp" || name?.startsWith("mcp_") ? "mcp" : "other";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Classify result events, preserving their legacy implicit-success default when status is omitted. */
|
|
59
|
+
export function classifyToolResult(event: Record<string, unknown>): {
|
|
60
|
+
kind: "pending" | "failure" | "success";
|
|
61
|
+
denied: boolean;
|
|
62
|
+
} {
|
|
63
|
+
const status = eventStatus(event);
|
|
64
|
+
const denied = isPermissionDenial(event);
|
|
65
|
+
if (denied || isErrorStatus(status)) return { kind: "failure", denied };
|
|
66
|
+
return { kind: isSuccessStatus(status) ? "success" : "pending", denied };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Fixed operation minima from terminal summaries, with no inferred call IDs or file targets. */
|
|
70
|
+
export function terminalDeniedOperations(
|
|
71
|
+
event: Record<string, unknown>,
|
|
72
|
+
): Map<ConsultingDeniedOperation, string> {
|
|
73
|
+
const operations = new Map<ConsultingDeniedOperation, string>();
|
|
74
|
+
if (!Array.isArray(event.denied_actions)) return operations;
|
|
75
|
+
for (const action of event.denied_actions) {
|
|
76
|
+
const name = isRecord(action) ? safeString(action.action, 80) : undefined;
|
|
77
|
+
if (!name) continue;
|
|
78
|
+
const normalizedName = normalizeToolName(name);
|
|
79
|
+
operations.set(classifyDeniedOperation(normalizedName), normalizedName);
|
|
80
|
+
}
|
|
81
|
+
return operations;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extract a file target: undefined means omitted; null means explicitly supplied but unavailable. */
|
|
85
|
+
function requestedFileResource(
|
|
86
|
+
name: string | undefined,
|
|
87
|
+
event: Record<string, unknown>,
|
|
88
|
+
): ConsultingFileResource | null | undefined {
|
|
89
|
+
const operation = classifyDeniedOperation(name);
|
|
90
|
+
if (operation !== "file-read" && operation !== "file-write") return undefined;
|
|
91
|
+
const field = structuredWorkspacePath(toolInput(event) ?? event);
|
|
92
|
+
if (!field) return undefined;
|
|
93
|
+
const path = field.value;
|
|
94
|
+
return typeof path === "string" &&
|
|
95
|
+
path.length > 0 &&
|
|
96
|
+
path.length <= MAX_FAILURE_RESOURCE_PATH_CHARS
|
|
97
|
+
? { operation, requestedPath: path }
|
|
98
|
+
: null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Bounded, deduplicated requested targets for one category of file observations. */
|
|
102
|
+
export class FileResources {
|
|
103
|
+
#resources: ConsultingFileResource[] = [];
|
|
104
|
+
#omitted = 0;
|
|
105
|
+
#unavailable = 0;
|
|
106
|
+
|
|
107
|
+
/** Record an observation, counting absent targets and capacity omissions without guessing. */
|
|
108
|
+
record(operation: ConsultingDeniedOperation, resource?: ConsultingFileResource | null): void {
|
|
109
|
+
if (operation !== "file-read" && operation !== "file-write") return;
|
|
110
|
+
if (!resource || resource.operation !== operation) {
|
|
111
|
+
this.#unavailable += 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
this.#resources.some(
|
|
116
|
+
(item) => item.operation === operation && item.requestedPath === resource.requestedPath,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return;
|
|
120
|
+
if (this.#resources.length === MAX_FAILURE_RESOURCES) {
|
|
121
|
+
this.#omitted += 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.#resources.push({ ...resource });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Copy metadata only; neither raw payloads nor inferred outcomes are exposed. */
|
|
128
|
+
get snapshot() {
|
|
129
|
+
return {
|
|
130
|
+
resources: this.#resources.map((resource) => ({ ...resource })),
|
|
131
|
+
omitted: this.#omitted,
|
|
132
|
+
unavailable: this.#unavailable,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Recognize denial observations without retaining the raw error or tool output. */
|
|
138
|
+
export function isPermissionDenial(event: Record<string, unknown>): boolean {
|
|
139
|
+
const status = eventStatus(event);
|
|
140
|
+
if (["denied", "permission_denied", "forbidden", "blocked"].includes(status ?? "")) return true;
|
|
141
|
+
const type = eventType(event);
|
|
142
|
+
if (type.includes("permission") || type.includes("denied") || type.includes("blocked"))
|
|
143
|
+
return true;
|
|
144
|
+
const hasExplicitFailure =
|
|
145
|
+
event.is_error === true ||
|
|
146
|
+
event.isError === true ||
|
|
147
|
+
isErrorStatus(status) ||
|
|
148
|
+
event.error !== undefined ||
|
|
149
|
+
event.reason !== undefined;
|
|
150
|
+
if (!hasExplicitFailure) return false;
|
|
151
|
+
const message = [event.error, event.reason, event.message, event.content]
|
|
152
|
+
.map((value) => safeDenialText(value))
|
|
153
|
+
.join(" ")
|
|
154
|
+
.toLowerCase();
|
|
155
|
+
return message.includes("permission") || message.includes("denied");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function safeDenialText(value: unknown, depth = 0): string {
|
|
159
|
+
if (depth > 3) return "";
|
|
160
|
+
if (typeof value === "string") return value.slice(0, 4_000);
|
|
161
|
+
if (Array.isArray(value)) {
|
|
162
|
+
return value
|
|
163
|
+
.slice(0, 20)
|
|
164
|
+
.map((item) => safeDenialText(item, depth + 1))
|
|
165
|
+
.join(" ");
|
|
166
|
+
}
|
|
167
|
+
if (!isRecord(value)) return "";
|
|
168
|
+
return ["text", "message", "content", "error", "reason"]
|
|
169
|
+
.map((key) => safeDenialText(value[key], depth + 1))
|
|
170
|
+
.join(" ");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Approved filesystem roots for diagnostic location classification, not permission enforcement. */
|
|
174
|
+
export interface DiagnosticReadScope {
|
|
175
|
+
workspaceDirectory?: string;
|
|
176
|
+
additionalReadOnlyDirectories?: readonly string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Correlated target facts; null invalidates an earlier resource instead of reviving a stale path. */
|
|
180
|
+
export interface FileTargetObservation {
|
|
181
|
+
requestedResource?: ConsultingFileResource | null;
|
|
182
|
+
outsideReadRoots?: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Preserve earlier facts only when a path is omitted; explicit replacements supersede both facts. */
|
|
186
|
+
export function fileTargetUpdate(
|
|
187
|
+
name: string | undefined,
|
|
188
|
+
event: Record<string, unknown>,
|
|
189
|
+
scope: DiagnosticReadScope,
|
|
190
|
+
previous?: FileTargetObservation,
|
|
191
|
+
): FileTargetObservation {
|
|
192
|
+
const operation = classifyDeniedOperation(name);
|
|
193
|
+
if (operation !== "file-read" && operation !== "file-write") return {};
|
|
194
|
+
const resource = requestedFileResource(name, event);
|
|
195
|
+
return resource === undefined
|
|
196
|
+
? {
|
|
197
|
+
requestedResource: previous?.requestedResource,
|
|
198
|
+
outsideReadRoots: previous?.outsideReadRoots,
|
|
199
|
+
}
|
|
200
|
+
: {
|
|
201
|
+
requestedResource: resource,
|
|
202
|
+
outsideReadRoots: fileAccessOutsideReadRoots(name, event, scope),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Classify the requested file location independently of the denial cause. Resolve
|
|
208
|
+
* aliases (including missing targets' existing parents); inaccessible paths and
|
|
209
|
+
* parent traversal stay unknown rather than relying on lexical symlink semantics.
|
|
210
|
+
*/
|
|
211
|
+
export function fileAccessOutsideReadRoots(
|
|
212
|
+
name: string | undefined,
|
|
213
|
+
event: Record<string, unknown>,
|
|
214
|
+
scope: DiagnosticReadScope,
|
|
215
|
+
): boolean | undefined {
|
|
216
|
+
const operation = classifyDeniedOperation(name);
|
|
217
|
+
if (operation !== "file-read" && operation !== "file-write") return undefined;
|
|
218
|
+
const path = extractWorkspacePath(toolInput(event) ?? event, { preserveWhitespace: true });
|
|
219
|
+
if (!path || !scope.workspaceDirectory) return undefined;
|
|
220
|
+
// Resolving '..' lexically can erase a symlink before its destination is inspected.
|
|
221
|
+
if (path.split(/[\\/]/).includes("..")) return undefined;
|
|
222
|
+
const target = canonicalDiagnosticPath(resolve(scope.workspaceDirectory, path));
|
|
223
|
+
const roots = [scope.workspaceDirectory, ...(scope.additionalReadOnlyDirectories ?? [])].map(
|
|
224
|
+
canonicalDiagnosticPath,
|
|
225
|
+
);
|
|
226
|
+
if (!target || roots.some((root) => root === undefined)) return undefined;
|
|
227
|
+
return !roots.some((root) => {
|
|
228
|
+
if (!root) return false;
|
|
229
|
+
const distance = relative(root, target);
|
|
230
|
+
return (
|
|
231
|
+
distance === "" || (!isAbsolute(distance) && distance !== ".." && !distance.startsWith("../"))
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function canonicalDiagnosticPath(path: string): string | undefined {
|
|
237
|
+
let candidate = path;
|
|
238
|
+
const suffix: string[] = [];
|
|
239
|
+
while (true) {
|
|
240
|
+
try {
|
|
241
|
+
return resolve(realpathSync(candidate), ...suffix.reverse());
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (!canResolveParent(candidate, error)) return undefined;
|
|
244
|
+
const parent = dirname(candidate);
|
|
245
|
+
if (parent === candidate) return undefined;
|
|
246
|
+
suffix.push(basename(candidate));
|
|
247
|
+
candidate = parent;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function canResolveParent(path: string, error: unknown): boolean {
|
|
253
|
+
if (!isRecord(error) || !["ENOENT", "ENOTDIR"].includes(String(error.code))) return false;
|
|
254
|
+
try {
|
|
255
|
+
// A dangling symlink is not a lexical child of its containing directory.
|
|
256
|
+
return !lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink();
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -1,4 +1,60 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ERROR_STATES,
|
|
3
|
+
eventStatus,
|
|
4
|
+
eventType,
|
|
5
|
+
isErrorStatus,
|
|
6
|
+
isRecord,
|
|
7
|
+
isSuccessStatus,
|
|
8
|
+
isToolResultEvent,
|
|
9
|
+
isToolStartEvent,
|
|
10
|
+
SUCCESS_STATES,
|
|
11
|
+
safeString,
|
|
12
|
+
toolName,
|
|
13
|
+
} from "./event-values.ts";
|
|
14
|
+
|
|
15
|
+
/** Terminal results need explicit success; the absence of an error is not enough. */
|
|
16
|
+
export function hasExplicitSuccess(
|
|
17
|
+
event: Record<string, unknown>,
|
|
18
|
+
status: string | undefined,
|
|
19
|
+
): boolean {
|
|
20
|
+
return status !== undefined
|
|
21
|
+
? isSuccessStatus(status)
|
|
22
|
+
: event.is_error === false || event.isError === false || event.success === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only explicit successful activity can establish observed evidence or complete a pending call. */
|
|
26
|
+
export function isSuccessfulActivity(event: Record<string, unknown>): boolean {
|
|
27
|
+
const status = eventStatus(event);
|
|
28
|
+
return (
|
|
29
|
+
!isErrorStatus(status) &&
|
|
30
|
+
isSuccessStatus(status) &&
|
|
31
|
+
(status !== undefined || event.success === true || event.ok === true)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Exclude planning/assistant steps even when their index matches a pending tool call. */
|
|
36
|
+
export function isNonToolStepUpdate(event: Record<string, unknown>): boolean {
|
|
37
|
+
const type = eventType(event);
|
|
38
|
+
if (!["assistant", "step_update"].includes(type)) return false;
|
|
39
|
+
const declaredType = safeString(event.step_type ?? event.stepType, 40)?.toLowerCase();
|
|
40
|
+
if (declaredType) return declaredType !== "tool";
|
|
41
|
+
return toolName(event) === undefined && !isRecord(event.tool_info) && !isRecord(event.toolInfo);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract normalized tool activity from assistant content without treating answer text as evidence. */
|
|
45
|
+
export function nestedToolEvents(event: Record<string, unknown>): Record<string, unknown>[] {
|
|
46
|
+
const content = isRecord(event.message) ? event.message.content : undefined;
|
|
47
|
+
if (!Array.isArray(content)) return [];
|
|
48
|
+
return content.filter(isRecord).map(normalizeProtocolEvent).filter(isToolActivityBlock);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isToolActivityBlock(event: Record<string, unknown>): boolean {
|
|
52
|
+
return (
|
|
53
|
+
isToolStartEvent(event) ||
|
|
54
|
+
isToolResultEvent(event) ||
|
|
55
|
+
(eventType(event) === "step_update" && !isNonToolStepUpdate(event))
|
|
56
|
+
);
|
|
57
|
+
}
|
|
2
58
|
|
|
3
59
|
/** Adapt an agy event envelope to the small event vocabulary used by the reducer. */
|
|
4
60
|
export function normalizeProtocolEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { type IsolatedAntigravityPaths, prepareIsolatedAntigravityHome } from "../isolated-home.ts";
|
|
5
6
|
import type { AntigravityExecutionFacts, AntigravityProgressCallback } from "../types.ts";
|
|
6
|
-
import { eventType, toolName } from "./event-values.ts";
|
|
7
|
+
import { eventType, isRecord, toolName } from "./event-values.ts";
|
|
7
8
|
import { AntigravityEventAccumulator } from "./events.ts";
|
|
8
9
|
import { MAX_STREAM_EVENTS, parseNdjsonLine, StreamLimitError } from "./ndjson.ts";
|
|
9
10
|
import { AntigravityResponseError } from "./response-error.ts";
|
|
@@ -16,28 +17,37 @@ const DEFAULT_PROBE_TIMEOUT_MS = 15 * 1_000;
|
|
|
16
17
|
export type { AntigravityProbeResult } from "./subprocess.ts";
|
|
17
18
|
export { AntigravityProcessError } from "./subprocess.ts";
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Register extra roots per process, never in the shared profile. The enforced
|
|
22
|
+
* write_file(*) and command(*) denials keep these additional roots inspection-only.
|
|
23
|
+
*/
|
|
24
|
+
export function buildAntigravityRunArguments(options: {
|
|
25
|
+
schemaPath: string;
|
|
26
|
+
model: string;
|
|
27
|
+
conversationId?: string;
|
|
28
|
+
workspaceDirectory?: string;
|
|
29
|
+
additionalReadOnlyDirectories?: readonly string[];
|
|
30
|
+
}): string[] {
|
|
26
31
|
return [
|
|
27
32
|
"--input-format",
|
|
28
33
|
"stream-json",
|
|
29
34
|
"--output-format",
|
|
30
35
|
"stream-json",
|
|
31
36
|
"--json-schema",
|
|
32
|
-
schemaPath,
|
|
37
|
+
options.schemaPath,
|
|
33
38
|
"--model",
|
|
34
|
-
model,
|
|
39
|
+
options.model,
|
|
35
40
|
"--print-timeout",
|
|
36
41
|
"5m",
|
|
37
42
|
"--sandbox",
|
|
38
|
-
...
|
|
43
|
+
...[
|
|
44
|
+
...new Set([
|
|
45
|
+
...(options.workspaceDirectory ? [options.workspaceDirectory] : []),
|
|
46
|
+
...(options.additionalReadOnlyDirectories ?? []),
|
|
47
|
+
]),
|
|
48
|
+
].flatMap((directory) => ["--add-dir", directory]),
|
|
39
49
|
"--disable-slash-commands",
|
|
40
|
-
...(conversationId ? ["--conversation", conversationId] : []),
|
|
50
|
+
...(options.conversationId ? ["--conversation", options.conversationId] : []),
|
|
41
51
|
];
|
|
42
52
|
}
|
|
43
53
|
|
|
@@ -60,6 +70,8 @@ export async function runAntigravityConversation(options: {
|
|
|
60
70
|
conversationId?: string;
|
|
61
71
|
/** Add the selected project directory to agy's sandbox workspace. */
|
|
62
72
|
workspaceDirectory?: string;
|
|
73
|
+
/** Canonical inspection roots; fixed profile rules still deny writes and commands. */
|
|
74
|
+
additionalReadOnlyDirectories?: readonly string[];
|
|
63
75
|
schema: Record<string, unknown>;
|
|
64
76
|
signal?: AbortSignal;
|
|
65
77
|
timeoutMs?: number;
|
|
@@ -73,16 +85,35 @@ export async function runAntigravityConversation(options: {
|
|
|
73
85
|
join(options.schemaDirectoryParent ?? tmpdir(), "supi-consulting-schema-"),
|
|
74
86
|
);
|
|
75
87
|
const schemaPath = join(schemaDirectory, "answer.json");
|
|
76
|
-
const
|
|
88
|
+
const responseToken = randomUUID();
|
|
89
|
+
const accumulator = new AntigravityEventAccumulator({
|
|
90
|
+
expectedResponseToken: responseToken,
|
|
91
|
+
workspaceDirectory: options.cwd,
|
|
92
|
+
additionalReadOnlyDirectories: options.additionalReadOnlyDirectories,
|
|
93
|
+
});
|
|
77
94
|
let eventCount = 0;
|
|
78
95
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
// A resumed CLI can replay its previous structured output. Bind and locally
|
|
97
|
+
// validate this invocation rather than trusting terminal success or schema shape.
|
|
98
|
+
const schema = {
|
|
99
|
+
...options.schema,
|
|
100
|
+
properties: {
|
|
101
|
+
...(isRecord(options.schema.properties) ? options.schema.properties : {}),
|
|
102
|
+
responseToken: { type: "string", enum: [responseToken] },
|
|
103
|
+
},
|
|
104
|
+
required: [
|
|
105
|
+
...(Array.isArray(options.schema.required) ? options.schema.required : []),
|
|
106
|
+
"responseToken",
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
await writeFile(schemaPath, `${JSON.stringify(schema)}\n`, { mode: 0o600 });
|
|
110
|
+
const args = buildAntigravityRunArguments({
|
|
81
111
|
schemaPath,
|
|
82
|
-
options.model,
|
|
83
|
-
options.conversationId,
|
|
84
|
-
options.workspaceDirectory,
|
|
85
|
-
|
|
112
|
+
model: options.model,
|
|
113
|
+
conversationId: options.conversationId,
|
|
114
|
+
workspaceDirectory: options.workspaceDirectory,
|
|
115
|
+
additionalReadOnlyDirectories: options.additionalReadOnlyDirectories,
|
|
116
|
+
});
|
|
86
117
|
await runBoundedChildProcess({
|
|
87
118
|
args,
|
|
88
119
|
cwd: options.cwd,
|
package/src/agents/errors.ts
CHANGED
|
@@ -15,6 +15,8 @@ const FAILURE_REASON_MESSAGES = {
|
|
|
15
15
|
"missing-answer": "The Consulting Agent did not return a final structured answer.",
|
|
16
16
|
"invalid-answer":
|
|
17
17
|
"The Consulting Agent returned an answer that does not match the required schema.",
|
|
18
|
+
"unbound-answer":
|
|
19
|
+
"The Consulting Agent's structured answer could not be verified for the current request. Start a new Consultation.",
|
|
18
20
|
"missing-conversation": "The Consulting Agent did not return a conversation identifier.",
|
|
19
21
|
"events-after-terminal": "The Consulting Agent sent data after its final result.",
|
|
20
22
|
"malformed-json": "The Consulting Agent returned malformed JSON.",
|
|
@@ -27,9 +29,49 @@ const FAILURE_REASON_MESSAGES = {
|
|
|
27
29
|
/** Fixed failure reasons that do not contain agent output. */
|
|
28
30
|
export type ConsultingFailureReason = keyof typeof FAILURE_REASON_MESSAGES;
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
const DENIED_OPERATIONS = [
|
|
33
|
+
"command",
|
|
34
|
+
"file-read",
|
|
35
|
+
"file-write",
|
|
36
|
+
"web-read",
|
|
37
|
+
"browser",
|
|
38
|
+
"mcp",
|
|
39
|
+
"other",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
/** Fixed operation categories; raw agent tool names never leave the adapter on failure. */
|
|
43
|
+
export type ConsultingDeniedOperation = (typeof DENIED_OPERATIONS)[number];
|
|
44
|
+
|
|
45
|
+
/** Maximum distinct requested file targets in each failure resource list. */
|
|
46
|
+
export const MAX_FAILURE_RESOURCES = 8;
|
|
47
|
+
/** Longer paths are unavailable, never silently truncated into a different target. */
|
|
48
|
+
export const MAX_FAILURE_RESOURCE_PATH_CHARS = 1_024;
|
|
49
|
+
|
|
50
|
+
/** Structured requested file target, not a canonical path, rule decision, or outcome. */
|
|
51
|
+
export interface ConsultingFileResource {
|
|
52
|
+
operation: "file-read" | "file-write";
|
|
53
|
+
requestedPath: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Failure facts plus bounded requested file targets; never raw tool payloads or output. */
|
|
31
57
|
export interface ConsultingFailureDiagnostics {
|
|
32
58
|
permissionDenials?: number;
|
|
59
|
+
/** Deduplicated denial counts, classified into fixed operation categories. */
|
|
60
|
+
permissionDenialsByOperation?: Partial<Record<ConsultingDeniedOperation, number>>;
|
|
61
|
+
/** Denied file operations targeting outside approved roots; not a claim about denial cause. */
|
|
62
|
+
deniedFileAccessOutsideReadRoots?: number;
|
|
63
|
+
/** Distinct requested file targets extracted from correlated structured tool parameters. */
|
|
64
|
+
deniedResources?: readonly ConsultingFileResource[];
|
|
65
|
+
/** File-denial observations whose known targets were omitted because the list is full. */
|
|
66
|
+
deniedResourcesOmitted?: number;
|
|
67
|
+
/** Known file-denial observations without an available, bounded structured target. */
|
|
68
|
+
deniedResourcePathsUnavailable?: number;
|
|
69
|
+
/** Requested file targets of calls without a terminal outcome; not attributed to denials. */
|
|
70
|
+
unfinishedResources?: readonly ConsultingFileResource[];
|
|
71
|
+
/** Unfinished file observations omitted because their target list is full. */
|
|
72
|
+
unfinishedResourcesOmitted?: number;
|
|
73
|
+
/** Unfinished file observations lacking a usable structured target. */
|
|
74
|
+
unfinishedResourcePathsUnavailable?: number;
|
|
33
75
|
eventCount?: number;
|
|
34
76
|
terminalSeen?: boolean;
|
|
35
77
|
terminalSucceeded?: boolean;
|
|
@@ -40,7 +82,7 @@ export interface ConsultingFailureDiagnostics {
|
|
|
40
82
|
export type ConsultingAgentFailureKind = keyof typeof FAILURE_MESSAGES;
|
|
41
83
|
|
|
42
84
|
/**
|
|
43
|
-
* A bounded failure with fixed
|
|
85
|
+
* A bounded failure with fixed reason text and extracted execution metadata, but no raw cause.
|
|
44
86
|
* Permission denials are observations, not a claim about the cause of failure.
|
|
45
87
|
*/
|
|
46
88
|
export class ConsultingAgentError extends Error {
|
|
@@ -55,22 +97,160 @@ export class ConsultingAgentError extends Error {
|
|
|
55
97
|
const message = options.reason
|
|
56
98
|
? FAILURE_REASON_MESSAGES[options.reason]
|
|
57
99
|
: FAILURE_MESSAGES[kind];
|
|
58
|
-
const denial = (options.diagnostics?.permissionDenials ?? 0) > 0;
|
|
59
|
-
super(
|
|
60
|
-
`${message}${denial ? " A tool permission denial was observed. Check the requested resources and the Consulting Agent permissions." : ""}`,
|
|
61
|
-
);
|
|
62
|
-
this.name = "ConsultingAgentError";
|
|
63
|
-
this.kind = kind;
|
|
64
|
-
this.reason = options.reason;
|
|
65
100
|
const facts = options.diagnostics;
|
|
66
|
-
|
|
101
|
+
const diagnostics: ConsultingFailureDiagnostics | undefined = facts
|
|
67
102
|
? {
|
|
68
103
|
permissionDenials: facts.permissionDenials,
|
|
104
|
+
...(facts.permissionDenialsByOperation
|
|
105
|
+
? {
|
|
106
|
+
permissionDenialsByOperation: sanitizeOperationCounts(
|
|
107
|
+
facts.permissionDenialsByOperation,
|
|
108
|
+
),
|
|
109
|
+
}
|
|
110
|
+
: {}),
|
|
111
|
+
...(facts.deniedFileAccessOutsideReadRoots === undefined
|
|
112
|
+
? {}
|
|
113
|
+
: { deniedFileAccessOutsideReadRoots: facts.deniedFileAccessOutsideReadRoots }),
|
|
114
|
+
...(facts.deniedResources
|
|
115
|
+
? { deniedResources: sanitizeFileResources(facts.deniedResources) }
|
|
116
|
+
: {}),
|
|
117
|
+
deniedResourcesOmitted: facts.deniedResourcesOmitted,
|
|
118
|
+
deniedResourcePathsUnavailable: facts.deniedResourcePathsUnavailable,
|
|
119
|
+
...(facts.unfinishedResources
|
|
120
|
+
? { unfinishedResources: sanitizeFileResources(facts.unfinishedResources) }
|
|
121
|
+
: {}),
|
|
122
|
+
unfinishedResourcesOmitted: facts.unfinishedResourcesOmitted,
|
|
123
|
+
unfinishedResourcePathsUnavailable: facts.unfinishedResourcePathsUnavailable,
|
|
69
124
|
eventCount: facts.eventCount,
|
|
70
125
|
terminalSeen: facts.terminalSeen,
|
|
71
126
|
terminalSucceeded: facts.terminalSucceeded,
|
|
72
127
|
exitCode: facts.exitCode,
|
|
73
128
|
}
|
|
74
129
|
: undefined;
|
|
130
|
+
super(`${message}${formatFailureContext(diagnostics)}`);
|
|
131
|
+
this.name = "ConsultingAgentError";
|
|
132
|
+
this.kind = kind;
|
|
133
|
+
this.reason = options.reason;
|
|
134
|
+
this.diagnostics = diagnostics;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sanitizeFileResources(
|
|
139
|
+
resources: readonly ConsultingFileResource[],
|
|
140
|
+
): ConsultingFileResource[] {
|
|
141
|
+
return resources
|
|
142
|
+
.slice(0, MAX_FAILURE_RESOURCES)
|
|
143
|
+
.flatMap((resource) =>
|
|
144
|
+
(resource.operation === "file-read" || resource.operation === "file-write") &&
|
|
145
|
+
typeof resource.requestedPath === "string" &&
|
|
146
|
+
resource.requestedPath.length > 0 &&
|
|
147
|
+
resource.requestedPath.length <= MAX_FAILURE_RESOURCE_PATH_CHARS
|
|
148
|
+
? [{ operation: resource.operation, requestedPath: resource.requestedPath }]
|
|
149
|
+
: [],
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sanitizeOperationCounts(
|
|
154
|
+
counts: Partial<Record<ConsultingDeniedOperation, number>>,
|
|
155
|
+
): Partial<Record<ConsultingDeniedOperation, number>> {
|
|
156
|
+
const result: Partial<Record<ConsultingDeniedOperation, number>> = {};
|
|
157
|
+
for (const operation of DENIED_OPERATIONS) {
|
|
158
|
+
const count = counts[operation];
|
|
159
|
+
if (typeof count === "number" && Number.isSafeInteger(count) && count > 0) {
|
|
160
|
+
result[operation] = count;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatFailureContext(facts: ConsultingFailureDiagnostics | undefined): string {
|
|
167
|
+
if (!facts) return "";
|
|
168
|
+
const parts: string[] = [];
|
|
169
|
+
if (facts.terminalSeen !== undefined) {
|
|
170
|
+
const status = facts.terminalSeen
|
|
171
|
+
? facts.terminalSucceeded
|
|
172
|
+
? "success"
|
|
173
|
+
: "unsuccessful"
|
|
174
|
+
: "not observed";
|
|
175
|
+
parts.push(`Agent terminal status: ${status}.`);
|
|
176
|
+
}
|
|
177
|
+
const denial = formatPermissionDenials(facts);
|
|
178
|
+
if (denial) parts.push(denial);
|
|
179
|
+
const unfinished = formatUnfinishedResources(facts);
|
|
180
|
+
if (unfinished) parts.push(unfinished);
|
|
181
|
+
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatPermissionDenials(facts: ConsultingFailureDiagnostics): string {
|
|
185
|
+
if ((facts.permissionDenials ?? 0) === 0) return "";
|
|
186
|
+
const parts = [`Observed permission denials: ${facts.permissionDenials}.`];
|
|
187
|
+
const counts = Object.entries(facts.permissionDenialsByOperation ?? {});
|
|
188
|
+
if (counts.length > 0) {
|
|
189
|
+
parts.push(
|
|
190
|
+
`Permission denials by operation: ${counts.map(([operation, count]) => `${operation}=${count}`).join(", ")}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if ((facts.deniedFileAccessOutsideReadRoots ?? 0) > 0) {
|
|
194
|
+
parts.push(
|
|
195
|
+
`Denied file access outside approved read roots: ${facts.deniedFileAccessOutsideReadRoots}.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const resources = formatDeniedResources(facts);
|
|
199
|
+
if (resources) parts.push(resources);
|
|
200
|
+
parts.push("Permission denials are observations, not proof of the failure cause.");
|
|
201
|
+
if (facts.permissionDenialsByOperation?.command) {
|
|
202
|
+
parts.push("Workspace access does not grant command execution.");
|
|
203
|
+
}
|
|
204
|
+
parts.push(
|
|
205
|
+
"Do not retry denied actions unchanged. Use permitted inspection tools and artifacts in approved read roots, or request a limited/blocked report.",
|
|
206
|
+
);
|
|
207
|
+
return parts.join(" ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function quoteFileResources(resources: readonly ConsultingFileResource[]): string {
|
|
211
|
+
return JSON.stringify(resources).replace(
|
|
212
|
+
/[\u007f-\u009f\u2028\u2029]/g,
|
|
213
|
+
(character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatUnfinishedResources(facts: ConsultingFailureDiagnostics): string {
|
|
218
|
+
const parts: string[] = [];
|
|
219
|
+
if (facts.unfinishedResources?.length) {
|
|
220
|
+
parts.push(
|
|
221
|
+
`Requested file targets of unfinished actions: ${quoteFileResources(facts.unfinishedResources)}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if ((facts.unfinishedResourcesOmitted ?? 0) > 0) {
|
|
225
|
+
parts.push(
|
|
226
|
+
`Additional unfinished file observations omitted: ${facts.unfinishedResourcesOmitted}.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if ((facts.unfinishedResourcePathsUnavailable ?? 0) > 0) {
|
|
230
|
+
parts.push(
|
|
231
|
+
`Unfinished file observations with unavailable structured targets: ${facts.unfinishedResourcePathsUnavailable}.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (parts.length)
|
|
235
|
+
parts.push("Unfinished file operations have unknown outcomes; they are not confirmed denials.");
|
|
236
|
+
return parts.join(" ");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatDeniedResources(facts: ConsultingFailureDiagnostics): string {
|
|
240
|
+
const parts: string[] = [];
|
|
241
|
+
if (facts.deniedResources?.length) {
|
|
242
|
+
const targets = quoteFileResources(facts.deniedResources);
|
|
243
|
+
parts.push(`Requested file targets of denied actions: ${targets}.`);
|
|
244
|
+
}
|
|
245
|
+
if ((facts.deniedResourcesOmitted ?? 0) > 0) {
|
|
246
|
+
parts.push(
|
|
247
|
+
`Additional file-denial observations omitted from target list: ${facts.deniedResourcesOmitted}.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if ((facts.deniedResourcePathsUnavailable ?? 0) > 0) {
|
|
251
|
+
parts.push(
|
|
252
|
+
`File-denial observations with unavailable structured targets: ${facts.deniedResourcePathsUnavailable}.`,
|
|
253
|
+
);
|
|
75
254
|
}
|
|
255
|
+
return parts.join(" ");
|
|
76
256
|
}
|
package/src/agents/types.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface ConsultingAgentExecutionOptions {
|
|
|
25
25
|
model: ConsultingModel;
|
|
26
26
|
canonicalWorkingDirectory: string;
|
|
27
27
|
workspaceAccess: boolean;
|
|
28
|
+
/** Canonical extra directory roots for inspection; writes and commands remain denied. */
|
|
29
|
+
additionalReadOnlyDirectories?: readonly string[];
|
|
28
30
|
continuation?: string;
|
|
29
31
|
signal?: AbortSignal;
|
|
30
32
|
onProgress?: (activity: string) => void;
|