@mrclrchtr/supi-consulting 11.2.0 → 11.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## [11.3.0]
4
+
5
+ - Added package-specific release notes for bounded agent consultations.
6
+ - Consultations now use an explicit read-only Antigravity tool selection for web search, direct URL reading, and workspace inspection, with structured completion and observed source evidence.
7
+ - Follow-ups that ask the agent not to use tools no longer fail with an unverifiable answer.
8
+ - Reading cached web pages no longer reports workspace use or a misleading workspace warning.
9
+ - Requires Antigravity CLI `1.4.9` or newer. Start a new Consultation after upgrading: existing default-agent Conversation Handles cannot gain the new tools; they are rejected before process startup, retired, and recorded as `info`-level `consultation.rejected` Debug events instead of errors.
package/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@
9
9
  - Keep agent-specific models, authentication, process code, protocol parsing, and tool-activity classification in `src/agents/antigravity/`. Shared workflow uses the private interface in `src/agents/types.ts`.
10
10
  - Keep each Model Catalogue immutable until the next session start or reload. Register `consulting_run` only after agent discovery succeeds.
11
11
  - Require explicit agent, model, and Workspace Access for a new Consultation. Keep `new` and `continue` exact-one; follow-ups inherit all selections and their working directory.
12
- - Acquire a handle before process work. Report process start, including hook probes, through the adapter interface. Reject overlap before startup; retire only after a started follow-up fails or is canceled.
12
+ - Acquire a handle before process work. Report process start, including hook probes, through the adapter interface. Reject overlap before startup; retire after a started follow-up fails or is canceled, or when the adapter rejects incompatible continuation state (which can never succeed).
13
13
  - Keep raw conversation IDs opaque to shared policy. Persist only new consulting handle records and results; old Antigravity state has no migration or legacy read path.
14
14
  - Use `<PI agent dir>/supi/consulting/antigravity/` for Antigravity state. Leave the old `supi/antigravity/` tree untouched.
15
15
  - Use the Isolated Antigravity Home for every `agy` process. On macOS, create and unlock its private keychain, exposed as the login keychain, before startup. Do not copy the normal profile, credentials, or PI provider environment.
package/README.md CHANGED
@@ -44,7 +44,7 @@ cd "<consultation-workspace>" &&
44
44
  HOME="<isolated-home>" AGY_CLI_DISABLE_AUTO_UPDATE=true agy
45
45
  ```
46
46
 
47
- On macOS, the adapter creates and unlocks a private keychain inside the Isolated Antigravity Home. It exposes that keychain as the login keychain without prompting for the normal keychain password. Run the displayed command, sign in, exit Antigravity, and reload Pi. The installed `agy` version must be at least `1.1.24`.
47
+ On macOS, the adapter creates and unlocks a private keychain inside the Isolated Antigravity Home. It exposes that keychain as the login keychain without prompting for the normal keychain password. Run the displayed command, sign in, exit Antigravity, and reload Pi. The installed `agy` version must be at least `1.4.9`.
48
48
 
49
49
  If macOS displays `antigravity.` in a warning, the final `.` is sentence punctuation, not part of a keychain name.
50
50
 
@@ -76,10 +76,24 @@ A follow-up supplies only a returned Conversation Handle:
76
76
  }
77
77
  ```
78
78
 
79
- Supply exactly one of `new` and `continue`. Follow-ups retain the original agent, model, working directory, and Workspace Access. A handle is opaque and branch-aware. It is retired if a follow-up fails or is canceled after process work starts. Overlapping follow-ups on the same handle are rejected.
79
+ Supply exactly one of `new` and `continue`. Follow-ups retain the original agent, model, working directory, and Workspace Access. A handle is opaque and branch-aware. It is retired if a follow-up fails or is canceled after process work starts, or if its continuation state is incompatible. Overlapping follow-ups on the same handle are rejected.
80
80
 
81
81
  Results report web use, workspace use, observed and claimed references, warnings, token use, and the Conversation Handle. An answer can claim a reference without matching tool activity; SuPi labels that reference as claimed, not observed.
82
82
 
83
+ ### Inspection tools and existing handles
84
+
85
+ New Antigravity conversations use a package-owned temporary agent definition with explicit web search (`search_web`), direct URL reading (`read_url_content`), read-only file inspection (`view_file`, `list_dir`, `find_by_name`, `grep_search`), and structured completion (`finish`). The default Antigravity agent's tool selection is not used. A permission such as `read_url(*)` authorizes access; it does not itself make a URL-reading tool available.
86
+
87
+ The definition lives in the Isolated Antigravity Home under a random name to avoid workspace-agent name collisions. SuPi removes it after the process, including ordinary failures, timeouts, and cancellation. A hard process crash can leave a temporary definition behind. Follow-ups resume the saved conversation without a new agent override; Antigravity retains the original definition even after its file is removed.
88
+
89
+ **Start a new Consultation after upgrading from the default-agent integration.** Old Conversation Handles cannot acquire the inspection tools by resuming. SuPi rejects legacy or incompatible profile continuations before starting any Antigravity process, retires the handle, and asks for a new Consultation. Profile compatibility is tracked privately; the calling tool's input shape is unchanged.
90
+
91
+ Follow-up prompts get one fixed line asking the agent to end with `finish` using the current schema. Without it, a follow-up such as "do not use any tools" can skip `finish`, and Antigravity then replays the previous turn's answer, which SuPi rejects as unbound.
92
+
93
+ Antigravity caches fetched pages inside its own conversation state and may read them back with `view_file`. File activity whose path resolves outside the selected workspace and configured read-only directories is treated as agent-internal: it does not count as workspace use or workspace evidence. File calls without a classifiable path remain counted.
94
+
95
+ Web activity alone does not prove a cited page was read. For research, the agent is instructed to search for sources and directly read relevant pages before citing them. SuPi still classifies references from observed activity, not from the agent's claims. The CLI's startup tool inventory is not treated as proof that a tool is active for the selected agent.
96
+
83
97
  Use `/supi-settings` → **Consulting** to disable the tool. Disabling it skips availability checks and removes `consulting_run` from the active tool list.
84
98
 
85
99
  ## Additional read-only directories
@@ -120,13 +134,13 @@ An observed tool permission denial is reported separately. It does not prove the
120
134
 
121
135
  Failure messages returned to the calling agent include the observed terminal status and denial counts by fixed operation category (`command`, `file-read`, `file-write`, `web-read`, `browser`, `mcp`, or `other`). They distinguish a successful agent terminal status from a usable final answer and recommend permitted inspection or a limited/blocked report rather than repeating denied actions unchanged. This feedback remains available when Debug is disabled.
122
136
 
123
- When SuPi Debug is enabled, failed executions emit one `consulting/consultation.failed` event at `error` level. Cancellations emit `consulting/consultation.cancelled` at `info` level. Use the `debug` tool:
137
+ When SuPi Debug is enabled, failed executions emit one `consulting/consultation.failed` event at `error` level. Cancellations emit `consulting/consultation.cancelled` at `info` level. Rejected incompatible Conversation Handles emit `consulting/consultation.rejected` at `info` level; no process was started for them. Use the `debug` tool:
124
138
 
125
139
  ```json
126
140
  { "source": "consulting", "level": "error", "limit": 10 }
127
141
  ```
128
142
 
129
- Omit `level` to include cancellations. Add `sessionFile` to inspect events persisted in a saved PI session. A response aborted before the adapter starts has no Consulting event.
143
+ Omit `level` to include cancellations and rejected handles. Add `sessionFile` to inspect events persisted in a saved PI session. A response aborted before the adapter starts has no Consulting event.
130
144
 
131
145
  Events contain the execution phase, timing, selected model, workspace-access and follow-up flags, failure category and reason, and available event counts, terminal-state flags, permission-denial count, and exit code. `permissionDenialsByOperation` breaks down deduplicated denials by fixed category; unknown tool names become `other`. `deniedFileAccessOutsideReadRoots` counts denied file operations whose requested paths resolve outside the selected directory and configured additional read-only directories, following symlinks and existing parents of missing targets. Unavailable path information, dangling symlink destinations, and paths containing parent traversal (`..`) remain unclassified rather than risking an incorrect outside-root claim. This location observation does not identify which rule caused a denial.
132
146
 
@@ -163,4 +177,4 @@ The live probe requires separate approval and sign-in. It never runs in normal t
163
177
  SUPI_CONSULTING_LIVE=1 pnpm exec jiti packages/supi-consulting/scripts/live-probe.ts
164
178
  ```
165
179
 
166
- It uses the isolated profile and checks web evidence, follow-up context, workspace evidence, and byte-identical fixture state.
180
+ It uses the isolated profile and requires successful web search and direct URL reading, an observed source matching the fetched URL, and a schema-validated follow-up that retains context and the URL reader after the temporary definition has been removed. It also checks observed workspace evidence and byte-identical fixture state. The direct-reader checks use the private process interface; workspace checks use the Consulting Agent adapter. No paid capability checks run during normal discovery.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "11.2.0",
3
+ "version": "11.4.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,110 @@
1
+ // Session-provided Web tools for SuPi child sessions (Agent Runs and Reviewer Sessions).
2
+ //
3
+ // The loaded supi-web extension answers a synchronous request on the parent event bus.
4
+ // Consumers never depend on supi-web; when it is absent or provides no tools, the
5
+ // request returns undefined and the child simply lacks Web tools.
6
+
7
+ import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
8
+
9
+ /** Web tools that child sessions may receive when the containing session provides them. */
10
+ export const CHILD_WEB_TOOL_NAMES = [
11
+ "web_fetch_md",
12
+ "web_search",
13
+ "web_docs_search",
14
+ "web_docs_fetch",
15
+ ] as const;
16
+
17
+ /** One session-provided Web tool name. */
18
+ export type ChildWebToolName = (typeof CHILD_WEB_TOOL_NAMES)[number];
19
+
20
+ /**
21
+ * Web tools currently callable in the containing session plus a child-only extension
22
+ * factory that registers them. The factory must not capture the parent session; it may
23
+ * capture plain data such as the containing session's configuration directory.
24
+ */
25
+ export interface ChildWebToolProvision {
26
+ readonly toolNames: readonly ChildWebToolName[];
27
+ /** Register the requested subset of `toolNames` in a child session. */
28
+ readonly createFactory: (toolNames: readonly ChildWebToolName[]) => ExtensionFactory;
29
+ }
30
+
31
+ /** Plain containing-session data supplied with a child Web tool request. */
32
+ export interface ChildWebToolsRequestContext {
33
+ /**
34
+ * Working directory whose SuPi configuration governs Web settings such as Firecrawl.
35
+ * Children with a different cwd (for example a frozen Review Workspace) keep this policy.
36
+ */
37
+ readonly configCwd: string;
38
+ }
39
+
40
+ type EventBusLike = Pick<ExtensionAPI["events"], "on" | "emit">;
41
+
42
+ const REQUEST_CHANNEL = "supi:child-web-tools:request";
43
+ const RESPONSE_CHANNEL = "supi:child-web-tools:response";
44
+
45
+ interface ChildWebToolsRequest extends ChildWebToolsRequestContext {
46
+ readonly requestId: string;
47
+ }
48
+
49
+ interface ChildWebToolsResponse {
50
+ readonly requestId: string;
51
+ readonly provision: ChildWebToolProvision;
52
+ }
53
+
54
+ let nextRequestId = 0;
55
+
56
+ /**
57
+ * Answer child Web tool requests on the parent event bus. The callback must be synchronous
58
+ * because Pi's event bus does not await listeners. Returns an unsubscribe function.
59
+ */
60
+ export function provideChildWebTools(
61
+ events: EventBusLike,
62
+ resolve: (context: ChildWebToolsRequestContext) => ChildWebToolProvision | undefined,
63
+ ): () => void {
64
+ return events.on(REQUEST_CHANNEL, (data) => {
65
+ const request = data as Partial<ChildWebToolsRequest> | undefined;
66
+ if (typeof request?.requestId !== "string" || typeof request.configCwd !== "string") return;
67
+ const { requestId, configCwd } = request;
68
+ const provision = resolve({ configCwd });
69
+ if (!provision || provision.toolNames.length === 0) return;
70
+ events.emit(RESPONSE_CHANNEL, { requestId, provision } satisfies ChildWebToolsResponse);
71
+ });
72
+ }
73
+
74
+ /** Capture the Web tools the containing session currently provides, if any. */
75
+ export function requestChildWebTools(
76
+ events: EventBusLike,
77
+ context: ChildWebToolsRequestContext,
78
+ ): ChildWebToolProvision | undefined {
79
+ const requestId = `child-web-${process.pid}-${++nextRequestId}`;
80
+ let provision: ChildWebToolProvision | undefined;
81
+ const unsubscribe = events.on(RESPONSE_CHANNEL, (data) => {
82
+ const response = data as Partial<ChildWebToolsResponse> | undefined;
83
+ if (response?.requestId === requestId && response.provision) provision = response.provision;
84
+ });
85
+ try {
86
+ events.emit(REQUEST_CHANNEL, { requestId, ...context } satisfies ChildWebToolsRequest);
87
+ } finally {
88
+ unsubscribe();
89
+ }
90
+ return provision;
91
+ }
92
+
93
+ /**
94
+ * Return the Web tools callable in a session under Pi's exposure rules: active `direct`
95
+ * tools, plus registered `codemode` and `deferred` tools. `model-only` and `hidden`
96
+ * tools are not callable.
97
+ */
98
+ export function callableChildWebToolNames(
99
+ pi: Pick<ExtensionAPI, "getAllTools" | "getActiveTools">,
100
+ ): ChildWebToolName[] {
101
+ const active = new Set(pi.getActiveTools());
102
+ const exposures = new Map(
103
+ pi.getAllTools().map((tool) => [tool.name, tool.exposure ?? "direct"] as const),
104
+ );
105
+ return CHILD_WEB_TOOL_NAMES.filter((name) => {
106
+ const exposure = exposures.get(name);
107
+ if (exposure === "direct") return active.has(name);
108
+ return exposure === "codemode" || exposure === "deferred";
109
+ });
110
+ }
@@ -1,5 +1,16 @@
1
1
  // supi-core session domain — session utilities and registries.
2
2
 
3
+ export type {
4
+ ChildWebToolName,
5
+ ChildWebToolProvision,
6
+ ChildWebToolsRequestContext,
7
+ } from "./child-web-tools.ts";
8
+ export {
9
+ CHILD_WEB_TOOL_NAMES,
10
+ callableChildWebToolNames,
11
+ provideChildWebTools,
12
+ requestChildWebTools,
13
+ } from "./child-web-tools.ts";
3
14
  export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
4
15
  export type {
5
16
  SessionCapabilitySkill,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-consulting",
3
- "version": "11.2.0",
3
+ "version": "11.4.0",
4
4
  "description": "Bounded consultations with explicit agent and workspace selection and observed evidence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,6 +29,7 @@
29
29
  ],
30
30
  "type": "module",
31
31
  "files": [
32
+ "CHANGELOG.md",
32
33
  "src/**/*.ts",
33
34
  "scripts/live-probe.ts",
34
35
  "README.md",
@@ -37,7 +38,7 @@
37
38
  "docs/adr/*.md"
38
39
  ],
39
40
  "dependencies": {
40
- "@mrclrchtr/supi-core": "11.2.0"
41
+ "@mrclrchtr/supi-core": "11.4.0"
41
42
  },
42
43
  "bundledDependencies": [
43
44
  "@mrclrchtr/supi-core"
@@ -4,11 +4,20 @@ import { tmpdir } from "node:os";
4
4
  import { join, relative } from "node:path";
5
5
  import { createAntigravityAdapter } from "../src/agents/antigravity/adapter.ts";
6
6
  import { getIsolatedAntigravityPaths } from "../src/agents/antigravity/isolated-home.ts";
7
- import { isHttpUrl, isSafeWorkspacePath } from "../src/tool/consulting_run/evidence.ts";
7
+ import { runAntigravityConversation } from "../src/agents/antigravity/process/runner.ts";
8
+ import { ANTIGRAVITY_ANSWER_SCHEMA } from "../src/agents/antigravity/structured-output.ts";
9
+ import type { AntigravityExecutionFacts } from "../src/agents/antigravity/types.ts";
10
+ import {
11
+ classifyAnswerEvidence,
12
+ isHttpUrl,
13
+ isSafeWorkspacePath,
14
+ } from "../src/tool/consulting_run/evidence.ts";
15
+ import type { ConsultationExecutionFacts } from "../src/types.ts";
8
16
 
9
17
  const LIVE_ENVIRONMENT = "SUPI_CONSULTING_LIVE";
10
18
  const MODEL = "gemini-3.8-flash-low";
11
19
  const CONTEXT_TOKEN = "supi-consulting-context-token";
20
+ const SOURCE_URL = "https://nodejs.org/api/child_process.html";
12
21
 
13
22
  async function main(): Promise<void> {
14
23
  if (process.env[LIVE_ENVIRONMENT] !== "1") {
@@ -21,22 +30,56 @@ async function main(): Promise<void> {
21
30
  throw new Error("The selected model is not available to the isolated Antigravity account.");
22
31
  }
23
32
  const consultationWorkspace = await realpath(paths.consultationWorkspace);
24
- const first = await adapter.execute({
25
- prompt: `Remember the exact token ${CONTEXT_TOKEN}. Name one current official Node.js documentation page about subprocess cancellation and cite it.`,
33
+ // The process interface exposes successful tool names without adding them to the
34
+ // public Consultation contract. Mere web activity or claimed URLs cannot pass.
35
+ const processOptions = {
36
+ paths,
26
37
  model: MODEL,
27
- canonicalWorkingDirectory: consultationWorkspace,
28
- workspaceAccess: false,
38
+ cwd: consultationWorkspace,
39
+ schema: ANTIGRAVITY_ANSWER_SCHEMA as Record<string, unknown>,
40
+ };
41
+ const first = await runAntigravityConversation({
42
+ ...processOptions,
43
+ prompt: `Remember the exact token ${CONTEXT_TOKEN}. Use search_web to find official Node.js subprocess cancellation documentation, then use read_url_content to read ${SOURCE_URL}. Explain cancellation briefly and cite that exact fetched URL.`,
29
44
  });
30
- requireWebEvidence(first);
45
+ if (!first.successfulToolNames.includes("search_web")) {
46
+ throw new Error("The live probe did not observe a successful web search.");
47
+ }
48
+ requireDirectRead(first, consultationWorkspace);
31
49
 
32
- const second = await adapter.execute({
33
- prompt:
34
- "Without using tools, return only the exact context token I gave you in the preceding request.",
50
+ // The first run has already removed its temporary profile. Resume only by ID,
51
+ // proving the CLI retains the reader and finish capability in conversation state.
52
+ const second = await runAntigravityConversation({
53
+ ...processOptions,
54
+ prompt: `Read ${SOURCE_URL} again using read_url_content and cite that exact URL. In the answer field return only the exact context token I gave you in the preceding request.`,
55
+ conversationId: first.conversationId,
56
+ });
57
+ if (second.conversationId !== first.conversationId) {
58
+ throw new Error("The live probe resumed a different conversation.");
59
+ }
60
+ requireDirectRead(second, consultationWorkspace);
61
+
62
+ // A tool-free follow-up used to skip finish and replay the previous answer.
63
+ const toolFree = await adapter.execute({
64
+ prompt: "Return only the exact context token I gave you earlier. Do not use any tools.",
35
65
  model: MODEL,
36
66
  canonicalWorkingDirectory: consultationWorkspace,
37
67
  workspaceAccess: false,
38
- continuation: first.continuation,
68
+ continuation: (
69
+ await adapter.execute({
70
+ prompt: `Remember the exact token ${CONTEXT_TOKEN}. Use read_url_content to read ${SOURCE_URL} and cite it.`,
71
+ model: MODEL,
72
+ canonicalWorkingDirectory: consultationWorkspace,
73
+ workspaceAccess: false,
74
+ })
75
+ ).continuation,
39
76
  });
77
+ if (toolFree.answer.answer.trim() !== CONTEXT_TOKEN) {
78
+ throw new Error("The tool-free follow-up did not return the current context token.");
79
+ }
80
+ if (toolFree.workspaceUsed) {
81
+ throw new Error("Cached page reads were misreported as workspace use.");
82
+ }
40
83
  if (second.answer.answer.trim() !== CONTEXT_TOKEN) {
41
84
  throw new Error(
42
85
  "The follow-up did not return the requested current-turn answer from conversation context.",
@@ -56,7 +99,7 @@ async function main(): Promise<void> {
56
99
  canonicalWorkingDirectory: fixture,
57
100
  workspaceAccess: true,
58
101
  });
59
- requireWebEvidence(workspace);
102
+ requireWebEvidence(workspace, fixture);
60
103
  requireWorkspaceEvidence(workspace, fixture);
61
104
  if (!workspace.workspaceUsed) {
62
105
  throw new Error("The workspace probe did not observe workspace activity.");
@@ -68,40 +111,42 @@ async function main(): Promise<void> {
68
111
  }
69
112
  }
70
113
 
71
- function requireWebEvidence(result: { webUsed: boolean; answer: { sources: unknown[] } }): void {
72
- if (!result.webUsed || result.answer.sources.length === 0) {
114
+ type EvidenceFacts = Pick<
115
+ ConsultationExecutionFacts,
116
+ "answer" | "observedSourceHashes" | "observedWorkspacePathHashes"
117
+ >;
118
+
119
+ function requireDirectRead(result: AntigravityExecutionFacts, workspace: string): void {
120
+ if (!result.successfulToolNames.includes("read_url_content")) {
121
+ throw new Error("The live probe did not observe a successful direct URL read.");
122
+ }
123
+ requireWebEvidence(result, workspace);
124
+ const evidence = classifyAnswerEvidence(result.answer, result, workspace);
125
+ if (!evidence.observedSources.some((source) => source.url === SOURCE_URL)) {
126
+ throw new Error("The live probe did not return the fetched URL as observed evidence.");
127
+ }
128
+ }
129
+
130
+ function requireWebEvidence(result: EvidenceFacts, workspace: string): void {
131
+ const evidence = classifyAnswerEvidence(result.answer, result, workspace);
132
+ if (evidence.observedSources.length === 0) {
73
133
  throw new Error("The live probe did not observe web evidence.");
74
134
  }
75
- if (
76
- !result.answer.sources.every(
77
- (source) => isRecord(source) && typeof source.url === "string" && isHttpUrl(source.url),
78
- )
79
- ) {
135
+ if (!result.answer.sources.every((source) => isHttpUrl(source.url))) {
80
136
  throw new Error("The live probe returned an invalid source URL.");
81
137
  }
82
138
  }
83
139
 
84
- function requireWorkspaceEvidence(
85
- result: { answer: { workspaceEvidence: unknown[] } },
86
- workspace: string,
87
- ): void {
140
+ function requireWorkspaceEvidence(result: EvidenceFacts, workspace: string): void {
141
+ const evidence = classifyAnswerEvidence(result.answer, result, workspace);
88
142
  if (
89
- result.answer.workspaceEvidence.length === 0 ||
90
- !result.answer.workspaceEvidence.every(
91
- (item) =>
92
- isRecord(item) &&
93
- typeof item.path === "string" &&
94
- isSafeWorkspacePath(item.path, workspace),
95
- )
143
+ evidence.observedWorkspaceEvidence.length === 0 ||
144
+ !result.answer.workspaceEvidence.every((item) => isSafeWorkspacePath(item.path, workspace))
96
145
  ) {
97
- throw new Error("The live probe did not return valid workspace evidence.");
146
+ throw new Error("The live probe did not return observed, valid workspace evidence.");
98
147
  }
99
148
  }
100
149
 
101
- function isRecord(value: unknown): value is Record<string, unknown> {
102
- return typeof value === "object" && value !== null && !Array.isArray(value);
103
- }
104
-
105
150
  async function digestTree(directory: string): Promise<string> {
106
151
  const hash = createHash("sha256");
107
152
  await digestDirectory(directory, directory, hash);
@@ -9,6 +9,7 @@ import type {
9
9
  } from "../types.ts";
10
10
  import { isWebToolName, isWorkspaceToolName } from "./activity.ts";
11
11
  import { discoverAntigravityAvailability } from "./availability.ts";
12
+ import { INSPECTION_AGENT_FINGERPRINT } from "./inspection-agent.ts";
12
13
  import {
13
14
  getIsolatedAntigravityPaths,
14
15
  type IsolatedAntigravityPaths,
@@ -22,7 +23,12 @@ import { ANTIGRAVITY_ANSWER_SCHEMA } from "./structured-output.ts";
22
23
  const MAX_RETAINED_REFERENCE_HASHES = 512;
23
24
  const MAX_WARNING_COUNT = 24;
24
25
  const MAX_WARNING_CHARS = 500;
25
- const CONTINUATION_PREFIX = "agy1.";
26
+ // A resumed CLI retains its original profile and ignores --agent overrides.
27
+ // Reject pre-profile conversations and changed contracts before any process work.
28
+ const CONTINUATION_PREFIX = `agy2.${INSPECTION_AGENT_FINGERPRINT}.`;
29
+ // Follow-ups saying "do not use any tools" can skip finish; agy then replays the
30
+ // previous turn's structured answer, which the response token rejects. Tested live.
31
+ const FOLLOW_UP_RESPONSE_CONTRACT = "\n\n[Always end with finish using the current schema.]";
26
32
 
27
33
  type ExecutionPhase = "preparation" | "hook-probe" | "conversation" | "validation";
28
34
 
@@ -78,12 +84,12 @@ class AntigravityAdapter implements ConsultingAgentAdapter {
78
84
  });
79
85
  } catch (error) {
80
86
  const failure = safeExecutionFailure(error);
81
- const canceled = failure.kind === "cancelled";
87
+ const outcome = failureOutcome(failure.kind);
82
88
  timer.finish(
83
89
  () => ({
84
90
  source: "consulting",
85
- level: canceled ? "info" : "error",
86
- category: canceled ? "consultation.cancelled" : "consultation.failed",
91
+ level: outcome.level,
92
+ category: outcome.category,
87
93
  message: failure.message,
88
94
  cwd: options.canonicalWorkingDirectory,
89
95
  data: {
@@ -121,7 +127,9 @@ class AntigravityAdapter implements ConsultingAgentAdapter {
121
127
  const rawFacts = await runAntigravityConversation({
122
128
  paths: this.#paths,
123
129
  cwd: options.canonicalWorkingDirectory,
124
- prompt: options.prompt,
130
+ prompt: expectedConversationId
131
+ ? `${options.prompt}${FOLLOW_UP_RESPONSE_CONTRACT}`
132
+ : options.prompt,
125
133
  model: options.model,
126
134
  ...(expectedConversationId ? { conversationId: expectedConversationId } : {}),
127
135
  ...(options.workspaceAccess ? { workspaceDirectory: options.canonicalWorkingDirectory } : {}),
@@ -220,6 +228,19 @@ function decodeContinuation(value: string): string {
220
228
  return conversationId;
221
229
  }
222
230
 
231
+ /**
232
+ * Cancellations and stale handles are expected outcomes, not execution failures:
233
+ * an incompatible continuation is rejected before any process starts.
234
+ */
235
+ function failureOutcome(kind: ConsultingAgentFailureKind): {
236
+ level: "info" | "error";
237
+ category: string;
238
+ } {
239
+ if (kind === "cancelled") return { level: "info", category: "consultation.cancelled" };
240
+ if (kind === "invalid-continuation") return { level: "info", category: "consultation.rejected" };
241
+ return { level: "error", category: "consultation.failed" };
242
+ }
243
+
223
244
  /** Keep only fixed reasons and safe facts, including preparation and callback failures. */
224
245
  function safeExecutionFailure(error: unknown): ConsultingAgentError {
225
246
  if (error instanceof ConsultingAgentError) {
@@ -7,8 +7,8 @@ import { CURATED_MODELS, type CuratedModel, isCuratedModel } from "./models.ts";
7
7
  import type { AntigravityProbeResult } from "./process/runner.ts";
8
8
  import { AntigravityProcessError, runAntigravityProbe } from "./process/runner.ts";
9
9
 
10
- /** Minimum supported Antigravity CLI version. */
11
- export const MINIMUM_ANTIGRAVITY_VERSION = Object.freeze({ major: 1, minor: 1, patch: 24 });
10
+ /** Minimum CLI qualified for explicit inspection profiles, finish, and profile-free resumes. */
11
+ export const MINIMUM_ANTIGRAVITY_VERSION = Object.freeze({ major: 1, minor: 4, patch: 9 });
12
12
  const MAX_DISCOVERY_OUTPUT_BYTES = 128 * 1024;
13
13
 
14
14
  /** Parsed Antigravity CLI version. */
@@ -0,0 +1,53 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ // The generated name is deliberately excluded: it prevents workspace-name collisions,
6
+ // but is not part of the saved conversation's behavior or compatibility contract.
7
+ const DEFINITION = `description: Bounded read-only consultations with observed evidence.
8
+ mainAgent: true
9
+ subagent: false
10
+ tools:
11
+ - search_web
12
+ - read_url_content
13
+ - view_file
14
+ - list_dir
15
+ - find_by_name
16
+ - grep_search
17
+ - finish
18
+ ---
19
+ Provide bounded, read-only consultations using the available inspection tools.
20
+ Do not run commands, modify files, automate browsers, or delegate work.
21
+ For web research, use search_web to discover sources and read_url_content to verify relevant pages before citing them. Report limitations instead of claiming unperformed reads.
22
+ Return workspace evidence as workspace-relative paths; omit references outside the selected workspace.
23
+ Complete each request with finish using the current output schema.
24
+ `;
25
+
26
+ /** Compatibility marker for the static profile, not an attestation from Antigravity. */
27
+ export const INSPECTION_AGENT_FINGERPRINT = createHash("sha256").update(DEFINITION).digest("hex");
28
+
29
+ /**
30
+ * Create an exclusively owned profile for a new conversation. Antigravity retains
31
+ * its definition in conversation state, so callers remove it after the process ends.
32
+ * Keep this out of general home preparation: discovery and follow-ups need no profile.
33
+ */
34
+ export async function createInspectionAgent(
35
+ homeDir: string,
36
+ ): Promise<{ name: string; dispose: () => Promise<void> }> {
37
+ const parent = join(homeDir, ".gemini", "config", "agents");
38
+ await mkdir(parent, { recursive: true, mode: 0o700 });
39
+ const name = `supi-consulting-${randomUUID()}`;
40
+ const directory = join(parent, name);
41
+ await mkdir(directory, { mode: 0o700 });
42
+ const dispose = () => rm(directory, { recursive: true, force: true });
43
+ try {
44
+ await writeFile(join(directory, "agent.md"), `---\nname: ${name}\n${DEFINITION}`, {
45
+ mode: 0o600,
46
+ flag: "wx",
47
+ });
48
+ } catch (error) {
49
+ await dispose();
50
+ throw error;
51
+ }
52
+ return { name, dispose };
53
+ }
@@ -27,6 +27,7 @@ import {
27
27
  FileResources,
28
28
  type FileTargetObservation,
29
29
  fileTargetUpdate,
30
+ isAgentInternalFileActivity,
30
31
  isPermissionDenial,
31
32
  terminalDeniedOperations,
32
33
  } from "./permission-diagnostics.ts";
@@ -326,6 +327,7 @@ export class AntigravityEventAccumulator {
326
327
 
327
328
  #recordSuccessfulTool(name: string, event: Record<string, unknown>, pending?: PendingTool): void {
328
329
  if (!pending?.observed) this.#recordObservedTool(name);
330
+ if (isAgentInternalFileActivity(name, event, this.#readScope, pending)) return;
329
331
  this.#successfulToolNames.add(name);
330
332
  const input = toolInput(event) ?? event;
331
333
  const sourceHash = pending?.urlHash ?? hashValue(extractUrl(input));
@@ -203,6 +203,21 @@ export function fileTargetUpdate(
203
203
  };
204
204
  }
205
205
 
206
+ /**
207
+ * Antigravity caches fetched pages in its own conversation directory and reads them
208
+ * back with file tools. A successful file call proven outside the approved roots is
209
+ * agent-internal, not workspace use. Unknown or missing paths stay counted.
210
+ */
211
+ export function isAgentInternalFileActivity(
212
+ name: string,
213
+ event: Record<string, unknown>,
214
+ scope: DiagnosticReadScope,
215
+ pending?: FileTargetObservation,
216
+ ): boolean {
217
+ const target = pending?.requestedResource ? pending : fileTargetUpdate(name, event, scope);
218
+ return target.outsideReadRoots === true;
219
+ }
220
+
206
221
  /**
207
222
  * Classify the requested file location independently of the denial cause. Resolve
208
223
  * aliases (including missing targets' existing parents); inaccessible paths and
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { createInspectionAgent } from "../inspection-agent.ts";
5
6
  import { type IsolatedAntigravityPaths, prepareIsolatedAntigravityHome } from "../isolated-home.ts";
6
7
  import type { AntigravityExecutionFacts, AntigravityProgressCallback } from "../types.ts";
7
8
  import { eventType, isRecord, toolName } from "./event-values.ts";
@@ -24,6 +25,8 @@ export { AntigravityProcessError } from "./subprocess.ts";
24
25
  export function buildAntigravityRunArguments(options: {
25
26
  schemaPath: string;
26
27
  model: string;
28
+ /** Only new conversations select a profile; resumes retain their saved definition. */
29
+ agentName?: string;
27
30
  conversationId?: string;
28
31
  workspaceDirectory?: string;
29
32
  additionalReadOnlyDirectories?: readonly string[];
@@ -47,7 +50,11 @@ export function buildAntigravityRunArguments(options: {
47
50
  ]),
48
51
  ].flatMap((directory) => ["--add-dir", directory]),
49
52
  "--disable-slash-commands",
50
- ...(options.conversationId ? ["--conversation", options.conversationId] : []),
53
+ ...(options.conversationId
54
+ ? ["--conversation", options.conversationId]
55
+ : options.agentName
56
+ ? ["--agent", options.agentName]
57
+ : []),
51
58
  ];
52
59
  }
53
60
 
@@ -81,10 +88,8 @@ export async function runAntigravityConversation(options: {
81
88
  onProcessStart?: () => void;
82
89
  }): Promise<AntigravityExecutionFacts> {
83
90
  await prepareIsolatedAntigravityHome(options.paths);
84
- const schemaDirectory = await mkdtemp(
85
- join(options.schemaDirectoryParent ?? tmpdir(), "supi-consulting-schema-"),
86
- );
87
- const schemaPath = join(schemaDirectory, "answer.json");
91
+ let schemaDirectory: string | undefined;
92
+ let inspectionAgent: Awaited<ReturnType<typeof createInspectionAgent>> | undefined;
88
93
  const responseToken = randomUUID();
89
94
  const accumulator = new AntigravityEventAccumulator({
90
95
  expectedResponseToken: responseToken,
@@ -93,6 +98,13 @@ export async function runAntigravityConversation(options: {
93
98
  });
94
99
  let eventCount = 0;
95
100
  try {
101
+ if (!options.conversationId) {
102
+ inspectionAgent = await createInspectionAgent(options.paths.homeDir);
103
+ }
104
+ schemaDirectory = await mkdtemp(
105
+ join(options.schemaDirectoryParent ?? tmpdir(), "supi-consulting-schema-"),
106
+ );
107
+ const schemaPath = join(schemaDirectory, "answer.json");
96
108
  // A resumed CLI can replay its previous structured output. Bind and locally
97
109
  // validate this invocation rather than trusting terminal success or schema shape.
98
110
  const schema = {
@@ -110,6 +122,7 @@ export async function runAntigravityConversation(options: {
110
122
  const args = buildAntigravityRunArguments({
111
123
  schemaPath,
112
124
  model: options.model,
125
+ agentName: inspectionAgent?.name,
113
126
  conversationId: options.conversationId,
114
127
  workspaceDirectory: options.workspaceDirectory,
115
128
  additionalReadOnlyDirectories: options.additionalReadOnlyDirectories,
@@ -149,7 +162,10 @@ export async function runAntigravityConversation(options: {
149
162
  },
150
163
  );
151
164
  } finally {
152
- await rm(schemaDirectory, { recursive: true, force: true });
165
+ await Promise.all([
166
+ inspectionAgent?.dispose(),
167
+ schemaDirectory ? rm(schemaDirectory, { recursive: true, force: true }) : undefined,
168
+ ]);
153
169
  }
154
170
  }
155
171
 
@@ -3,7 +3,8 @@ const FAILURE_MESSAGES = {
3
3
  timeout: "The Consultation timed out.",
4
4
  cancelled: "The Consultation was canceled.",
5
5
  "invalid-response": "The Consulting Agent returned an invalid response.",
6
- "invalid-continuation": "Invalid Conversation Handle continuation state.",
6
+ "invalid-continuation":
7
+ "The Conversation Handle has invalid or incompatible continuation state. Start a new Consultation.",
7
8
  "conversation-mismatch":
8
9
  "The Consulting Agent returned a different conversation for this handle.",
9
10
  execution: "Consultation execution failed.",
package/src/catalogue.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
- import type { TSchema } from "typebox";
1
+ import { type TSchema, Type } from "typebox";
3
2
 
4
3
  /** Build a provider-facing model enum from one immutable agent catalogue. */
5
4
  export function buildModelCatalogueEnum(catalogue: readonly string[]): TSchema {
6
5
  if (catalogue.length === 0) {
7
6
  throw new Error("Cannot build a model enum from an empty Consulting Agent catalogue.");
8
7
  }
9
- return StringEnum([...catalogue] as [string, ...string[]], {
8
+ return Type.Enum([...catalogue] as [string, ...string[]], {
9
+ type: "string",
10
10
  description: "Model available in the current Consulting Agent catalogue.",
11
11
  });
12
12
  }
@@ -4,6 +4,7 @@ import type {
4
4
  ExtensionAPI,
5
5
  ExtensionContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { ConsultingAgentError } from "../../agents/errors.ts";
7
8
  import type { ConsultingAgentAdapter, ConsultingAgentAvailability } from "../../agents/types.ts";
8
9
  import { resolveAdditionalReadOnlyDirectories } from "../../config.ts";
9
10
  import {
@@ -167,9 +168,19 @@ async function runContinue(
167
168
  ...(hookWarning ? { hookWarning } : {}),
168
169
  });
169
170
  } catch (error) {
170
- if (processStarted) {
171
+ // Incompatible continuation state can never succeed, so retire it even though
172
+ // the adapter rejected it before any process work.
173
+ const incompatible =
174
+ error instanceof ConsultingAgentError && error.kind === "invalid-continuation";
175
+ if (processStarted || incompatible) {
171
176
  const retired = dependencies.handles.retire(record.handle);
172
- if (retired) appendHandleRetired(dependencies.pi, retired, "Consultation follow-up failed");
177
+ if (retired) {
178
+ appendHandleRetired(
179
+ dependencies.pi,
180
+ retired,
181
+ incompatible ? "Incompatible continuation state" : "Consultation follow-up failed",
182
+ );
183
+ }
173
184
  }
174
185
  throw error;
175
186
  } finally {
@@ -1,4 +1,3 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
1
  import { type TSchema, Type } from "typebox";
3
2
  import { Value } from "typebox/value";
4
3
  import { buildModelCatalogueEnum, isAvailableModel } from "../../catalogue.ts";
@@ -40,7 +39,8 @@ export function buildConsultingRunSchema(agent: string, catalogue: readonly stri
40
39
  new: Type.Optional(
41
40
  Type.Object(
42
41
  {
43
- agent: StringEnum([agent], {
42
+ agent: Type.Enum([agent], {
43
+ type: "string",
44
44
  description: "The Consulting Agent that will perform this Consultation.",
45
45
  }),
46
46
  model: buildModelCatalogueEnum(catalogue),