Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/agent-tools/src/desktop/canonical-tool-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ const READ_ONLY_CANONICAL_BLOCKED_TOOL_NAMES = new Set([
'request_feature_enable',
]);

/**
* Whether the canonical read-only ceiling (Explore/Verifier) removes this tool.
* Exposed so a runtime guard can treat a call that still arrives as a violation
* without keeping its own copy of the list.
*/
export function isReadOnlyCanonicalBlockedToolName(name: string): boolean {
return READ_ONLY_CANONICAL_BLOCKED_TOOL_NAMES.has(name) || isComputerUseRuntimeToolName(name);
}

/** Computer-use tools share the desktop_* namespace across native and MCP paths. */
export function isComputerUseRuntimeToolName(name: string): boolean {
return name.startsWith('desktop_');
Expand Down
1 change: 1 addition & 0 deletions packages/agent-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
filterCanonicalBuiltinMcpEntries,
filterCanonicalNativeToolCeiling,
isCanonicalBuiltinTurn,
isReadOnlyCanonicalBlockedToolName,
} from "./desktop/canonical-tool-policy.js";

export { getLocalBashEnvironment } from "./desktop/local-pi-tools.js";
Expand Down
6 changes: 6 additions & 0 deletions packages/config/src/goal-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export type GoalVerificationEvidenceMode = (typeof GOAL_VERIFICATION_EVIDENCE_MO
export const GOAL_EVALUATOR_MODEL_POLICIES = ['same-route-small-fast'] as const;
export const GOAL_VERIFIER_READONLY_PROFILE = 'goal-verifier-readonly';
export const GOAL_SUBAGENT_PROFILES = [GOAL_VERIFIER_READONLY_PROFILE] as const;
/**
* Network tools a Goal verifier child never receives. Verification adjudicates
* the workspace from local evidence, so these are withheld from the child's tool
* catalog, and the child's runtime guard refuses any call that still arrives.
*/
export const GOAL_VERIFIER_OFFLINE_TOOL_NAMES: readonly string[] = ['web_fetch', 'web_search'];

export interface GoalConfig {
/** Optional objective length cap. Absent means unlimited. */
Expand Down
1 change: 1 addition & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
GOAL_VERIFICATION_EVIDENCE_MODES,
GOAL_SUBAGENT_PROFILES,
GOAL_VERIFICATION_MODES,
GOAL_VERIFIER_OFFLINE_TOOL_NAMES,
GOAL_VERIFIER_READONLY_PROFILE,
parseGoalConfig,
} from './goal-config.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
import type { AssistantMessage } from '@earendil-works/pi-ai';
import { GOAL_VERIFIER_READONLY_PROFILE } from '@mavis/config';
import { GOAL_VERIFIER_OFFLINE_TOOL_NAMES, GOAL_VERIFIER_READONLY_PROFILE } from '@mavis/config';
import type { PiBeforeToolCallHook } from '@mavis/agent-core/pi-turn-runner';
import { isReadOnlyCanonicalBlockedToolName } from '@mavis/agent-tools';
import type { AgentExtension } from '@mavis/agent-runtime';
import type { VerificationHostContext } from '@mavis/goal';

import { summarizeCommittedPiGoalUsage } from '../../service/session-system/index.js';
import { renderGoalVerifierReminder } from './goal-verifier-reminder.js';

/**
* The only tools the builtin `verifier` role holds that a Goal verifier child
* must not have. Everything else it could reach — write, edit, todowrite, the
* whole `task*` family, memory, computer use — is already gone by the time a
* call reaches this guard: the role asset grants five tools and the canonical
* read-only ceiling subtracts the rest.
* The network tools the builtin `verifier` role holds that a Goal verifier
* child must not use. The child's tool catalog already withholds them; this set
* is the fallback for a call that still arrives, for example a name the model
* remembers from its role prompt.
*
* A refused network call is a recoverable model mistake: the child still holds
* `read`, `grep`, `glob` and `bash` and can reach the same evidence locally.
* Everything else outside the role's read-only ceiling — write, edit,
* todowrite, delegation, memory, computer use — is a hard violation instead
* and stops the run on its first call.
*/
const GOAL_VERIFIER_OFFLINE_TOOLS: ReadonlySet<string> = new Set(GOAL_VERIFIER_OFFLINE_TOOL_NAMES);

/**
* Consecutive child turns that may reach for a network tool before the run is
* stopped as a capability violation. Counting turns rather than calls keeps
* several parallel calls from one model response to a single mistake; a child
* that is refused and then tries again on its very next turn is looping.
*/
const GOAL_VERIFIER_DENIED_TOOLS = new Set(['web_fetch', 'web_search']);
const MAX_CONSECUTIVE_OFFLINE_TOOL_TURNS = 2;

/**
* Child turns in total that may reach for a network tool. Bounds a child that
* alternates refused network calls with local work, independently of the
* optional host turn cap.
*/
const MAX_OFFLINE_TOOL_TURNS = 3;

/** Structurally mirrors the v1 delegation runner's registry contract. */
export interface GoalVerifierChildRunIssue {
Expand All @@ -39,6 +60,12 @@ export interface GoalVerifierChildRunState {
childTurns: number;
tokens: number;
usageIncomplete: boolean;
/** Child turn in which a network tool call was last refused. */
lastOfflineToolTurn?: number;
/** Consecutive child turns, ending at `lastOfflineToolTurn`, that called a network tool. */
consecutiveOfflineToolTurns: number;
/** Child turns in total that called a network tool. */
offlineToolTurns: number;
issue?: GoalVerifierChildRunIssue;
}

Expand All @@ -47,7 +74,7 @@ export interface GoalVerifierChildRunState {
*
* The child runs on the ordinary delegation path under the builtin `verifier`
* role, so this holds no authority: a Turn that claims a run id gains a
* reminder, a two-tool denylist, and a hard turn/token ceiling — all of them
* reminder, a narrow tool guard, and a hard turn/token ceiling — all of them
* restrictions. There is nothing here worth forging.
*
* The ceiling still has to live somewhere: `pi-turn-runner` has no generic
Expand All @@ -69,6 +96,8 @@ export class GoalVerifierChildCoordinator {
childTurns: 0,
tokens: 0,
usageIncomplete: false,
consecutiveOfflineToolTurns: 0,
offlineToolTurns: 0,
});
}

Expand Down Expand Up @@ -159,14 +188,51 @@ export class GoalVerifierChildCoordinator {
};
}
const toolName = toolContext.toolCall.name;
if (!GOAL_VERIFIER_DENIED_TOOLS.has(toolName)) return undefined;
if (GOAL_VERIFIER_OFFLINE_TOOLS.has(toolName)) return this.refuseOfflineTool(state, toolName);
if (!isReadOnlyCanonicalBlockedToolName(toolName)) return undefined;
const reason = `GOAL_VERIFIER_CAPABILITY_VIOLATION: readonly Goal verification blocked tool "${toolName}".`;
// Latched, not aborted here: the blocked result goes back to the model and
// the next `beforeLlm` stops the run on the latched issue.
this.latchIssue(state, { code: 'capability_violation', message: reason });
return { block: true, reason };
}

/**
* Refuses a network tool call and tells the child how to continue offline.
*
* Only a child that keeps reaching for the network after being refused is a
* capability violation; every call made in one model response shares that
* response's turn, so parallel calls count once.
*/
private refuseOfflineTool(
state: GoalVerifierChildRunState,
toolName: string,
): { readonly block: true; readonly reason: string } {
const turn = state.childTurns;
if (state.lastOfflineToolTurn !== turn) {
state.consecutiveOfflineToolTurns =
state.lastOfflineToolTurn === turn - 1 ? state.consecutiveOfflineToolTurns + 1 : 1;
state.offlineToolTurns += 1;
state.lastOfflineToolTurn = turn;
}
if (
state.consecutiveOfflineToolTurns >= MAX_CONSECUTIVE_OFFLINE_TOOL_TURNS ||
state.offlineToolTurns >= MAX_OFFLINE_TOOL_TURNS
) {
const reason = `GOAL_VERIFIER_CAPABILITY_VIOLATION: readonly Goal verification kept calling network tool "${toolName}" after it was refused.`;
this.latchIssue(state, { code: 'capability_violation', message: reason });
return { block: true, reason };
}
return {
block: true,
reason:
`"${toolName}" is unavailable: Goal verification runs offline. ` +
'Verify the objective from local evidence instead: read the files, search the tree, ' +
'and inspect the repository with git. If the evidence you need is not available ' +
'locally, judge the objective PARTIAL. Do not call web_fetch or web_search again.',
};
}

private latchIssue(state: GoalVerifierChildRunState, issue: GoalVerifierChildRunIssue): void {
state.issue ??= issue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ not change Git state: no \`git add\`, \`stash\`, \`checkout\`, \`restore\`,
the working tree, or refs. Inspecting with \`git status\`, \`git diff\`, and
\`git log\` is expected and safe.

This run is also offline: \`web_fetch\` and \`web_search\` are not available,
and any call to them is rejected. Verify the objective from local evidence —
read the files, search the tree, and inspect the repository with git. If the
evidence you need does not exist locally, that is an evidence gap: return
PARTIAL rather than reaching for the network.

Include exactly one verdict statement in your reply, using one of these tokens:

VERDICT: PASS
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { RuntimeTool } from '@mavis/agent-core/tools';
import type { ModelContextAssemblyCtx } from '@mavis/agent-runtime';
import type { IModelCapabilities } from '@mavis/protocol';
import type { ResolvedAgentCapabilities } from '@mavis/config';
import {
GOAL_VERIFIER_OFFLINE_TOOL_NAMES,
GOAL_VERIFIER_READONLY_PROFILE,
type ResolvedAgentCapabilities,
} from '@mavis/config';
import type { LocalTurnAgentProfileFacts } from './local-turn-tool-catalog.js';
import type { AgentHostTurnCapabilityView } from './turn-capability-lifecycle.js';

Expand Down Expand Up @@ -33,6 +37,8 @@ export interface LocalTurnToolCatalogBuildInput {
readonly userText?: string;
readonly agentProfile?: LocalTurnAgentProfileFacts;
readonly desktopCapabilities?: AgentHostTurnCapabilityView;
/** Tool names this Turn must not receive from any source. */
readonly withheldToolNames?: readonly string[];
}

export function createLocalTurnToolCatalogInput(
Expand All @@ -42,6 +48,7 @@ export function createLocalTurnToolCatalogInput(
const modelCapabilities = readModelCapabilities(context.model);
const builtinCapabilities = readBuiltinCapabilities(context.agentConfig);
const agentProfile = readAgentProfile(context.agentConfig);
const withheldToolNames = readWithheldToolNames(context.turnIntent);
return {
sessionId: context.sessionId,
turnId: context.turnId,
Expand All @@ -56,9 +63,25 @@ export function createLocalTurnToolCatalogInput(
...(modelCapabilities ? { modelCapabilities } : {}),
...(agentProfile ? { agentProfile } : {}),
...(desktopCapabilities ? { desktopCapabilities } : {}),
...(withheldToolNames ? { withheldToolNames } : {}),
};
}

/**
* The Goal verifier child is an ordinary `verifier` delegation, and that role
* holds `web_fetch` and `web_search`. Goal verification runs offline, so the
* child's Turn is assembled without them rather than offered tools that its
* runtime guard would then refuse.
*/
function readWithheldToolNames(
intent: ModelContextAssemblyCtx['turnIntent'],
): readonly string[] | undefined {
return intent?.kind === 'goal-verifier' &&
intent.attributes?.profile === GOAL_VERIFIER_READONLY_PROFILE
? GOAL_VERIFIER_OFFLINE_TOOL_NAMES
: undefined;
}

function readEffectivePluginSkills(
agentConfig: Readonly<Record<string, unknown>>,
): readonly { readonly pluginName: string; readonly name: string }[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export interface BuildLocalTurnToolCatalogInput {
readonly name: string;
}[];
readonly userText?: string;
/** Tool names this Turn must not receive from any source (native, MCP, plugin). */
readonly withheldToolNames?: readonly string[];
}

/** The already-gated inventory shared by Turn assembly and Task capture. */
Expand All @@ -130,8 +132,12 @@ export function filterLocalTurnCapabilityInventory(input: {
readonly agentProfile?: LocalTurnAgentProfileFacts;
readonly desktopCapabilities?: AgentHostTurnCapabilityView;
readonly hostCapabilityRegistry?: HostCapabilityResolver;
readonly withheldToolNames?: readonly string[];
}): FilteredLocalTurnCapabilityInventory {
const excludedNames = getSuppressedToolNamesForModelCapabilities(input.modelCapabilities);
const excludedNames = new Set([
...getSuppressedToolNamesForModelCapabilities(input.modelCapabilities),
...(input.withheldToolNames ?? []),
]);
const profile = input.agentProfile;
const selector = createLocalAgentCapabilitySelector(profile?.configSelection);
const hasBrowserHostAlias = Boolean(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ function toolCatalogBuildOptions(
...(input.desktopCapabilities ? { desktopCapabilities: input.desktopCapabilities } : {}),
...(input.modelCapabilities ? { modelCapabilities: input.modelCapabilities } : {}),
...(input.agentProfile ? { agentProfile: input.agentProfile } : {}),
...(input.withheldToolNames ? { withheldToolNames: input.withheldToolNames } : {}),
...(options.config ? { config: options.config } : {}),
...(options.env ? { env: options.env } : {}),
...(options.emitDiagnostic ? { emitDiagnostic: options.emitDiagnostic } : {}),
Expand Down
Loading
Loading