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
18 changes: 18 additions & 0 deletions packages/local-runtime/src/agent/feature-owned-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export function resolveFeatureAwareBuiltinSkillNames(
gates: {
cuModeActive: boolean;
resumeCodexAvailable?: boolean;
/**
* Whether the `website_deploy` tool is wired for this Turn. It publishes
* through the managed MiniMax account gateway, so `false` (no account
* session) also drops the Skills that only make sense with that tool.
* Left undefined, the capability config alone decides.
*/
websiteDeployAvailable?: boolean;
miniappAvailable?: boolean;
canonicalRole?: string;
builtinAgent?: boolean;
Expand Down Expand Up @@ -44,6 +51,10 @@ export function resolveFeatureAwareBuiltinSkillNames(
if (gates.resumeCodexAvailable === true && isAgentBuiltinToolEnabled(capabilities, 'bash')) {
selected.add('resume-codex');
}
if (gates.websiteDeployAvailable === false) {
selected.delete('deploy-website');
selected.delete('edit-deployed-website');
}
for (const skillName of gates.disabledSkillNames ?? []) selected.delete(skillName);
return [...selected];
}
Expand Down Expand Up @@ -96,3 +107,10 @@ function hasTools(
): boolean {
return toolNames.every((toolName) => isAgentBuiltinToolEnabled(capabilities, toolName));
}

/** True when the host holds a MiniMax account session that can call the managed gateway. */
export function hasManagedAccountSession(
authContext: { accessToken?: string } | undefined,
): boolean {
return Boolean(authContext?.accessToken?.trim());
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ResolvedAgentCapabilities } from '@mavis/config';

import { resolveFeatureAwareBuiltinSkillNames } from '../agent/feature-owned-skills.js';
import {
hasManagedAccountSession,
resolveFeatureAwareBuiltinSkillNames,
} from '../agent/feature-owned-skills.js';
import type { LocalRuntimeSkillScope, LocalSkillCatalogScope } from '../skills/skill-service.js';
import { applyHostedCapabilityRestrictions } from './hosted-agent-capability-restrictions.js';
import { resolveHostedCuModeActive } from './hosted-agent-turn-runtime-facts.js';
Expand Down Expand Up @@ -90,6 +93,7 @@ function resolveHostedSkillScope(
builtinAgent: scope.agentPolicy.builtinAgent,
disabledSkillNames: restrictions.disabledBuiltinSkillNames,
resumeCodexAvailable: restrictions.resumeCodexAvailable === true,
websiteDeployAvailable: hasManagedAccountSession(host.authContextGetter?.()),
}),
cuModeActive,
};
Expand Down
25 changes: 18 additions & 7 deletions packages/local-runtime/src/api/local-native-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import {
type AgentBuiltinSkillId,
type ResolvedAgentCapabilities,
} from "@mavis/config";
import { resolveFeatureAwareBuiltinSkillNames } from "../agent/feature-owned-skills.js";
import {
hasManagedAccountSession,
resolveFeatureAwareBuiltinSkillNames,
} from "../agent/feature-owned-skills.js";
import type { ModuleMetricsReporter } from "../common/metrics.js";
import { CU_DESKTOP_SKILL_NAME } from "../cu/gate.js";
import type { LocalMemoryFacade } from "../memory/local-memory-facade.js";
Expand Down Expand Up @@ -124,6 +127,7 @@ export function buildLocalNativeRuntimeTools(input: {
...(input.miniappAvailable === true ? { miniappAvailable: true } : {}),
disabledSkillNames: input.disabledBuiltinSkillNames,
resumeCodexAvailable: input.resumeCodexAvailable === true,
websiteDeployAvailable: hasManagedAccountSession(input.authContext),
});
const nativeTools = [
...buildLocalToolRegistry({
Expand Down Expand Up @@ -228,12 +232,19 @@ export function buildLocalNativeRuntimeTools(input: {
mavisCronAdapter: input.mavisCronAdapter,
mavisMcpAdapter: input.mavisMcpAdapter,
mavisSessionAdapter: input.mavisSessionAdapter,
websiteDeployAdapter: new LocalWebsiteDeployClient({
...(input.authContext ? { authContext: input.authContext } : {}),
...(input.dataDir ? { dataDir: input.dataDir } : {}),
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
routingContextGetter: input.routingContextGetter,
}),
// website_deploy publishes through the managed MiniMax account gateway,
// so it is only useful (and only worth its ~860-token schema plus the
// dependent skills) when an account access token is present.
...(hasManagedAccountSession(input.authContext)
? {
websiteDeployAdapter: new LocalWebsiteDeployClient({
authContext: input.authContext,
...(input.dataDir ? { dataDir: input.dataDir } : {}),
...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
routingContextGetter: input.routingContextGetter,
}),
}
: {}),
codeReviewAdapter: input.codeReviewAdapter,
}).values(),
];
Expand Down
2 changes: 1 addition & 1 deletion packages/local-runtime/src/skills/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createDefaultTokenEstimator, type TokenEstimator } from '@mavis/context-manager';

export const LOCAL_SKILL_CATALOG_MAX_BUDGET_TOKENS = 5_000;
export const LOCAL_SKILL_DESCRIPTION_MAX_CODE_POINTS = 1_024;
export const LOCAL_SKILL_DESCRIPTION_MAX_CODE_POINTS = 300;
const PROTECTED_SKILL_NAMES = new Set(['mcode-tools-master', 'minimax-code-product']);

const PARTIAL_DESCRIPTION_NOTICE =
Expand Down
20 changes: 16 additions & 4 deletions packages/local-runtime/src/thread-goal/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,24 @@ export class GoalLifecycle {

async runtimeToolsFor(
disabled: boolean,
_sessionId: string,
sessionId: string,
): Promise<RuntimeTool<TSchema, ToolExecutionContext>[]> {
if (!this.deps.runtime.isEnabled() || disabled) return [];
return buildThreadGoalRuntimeTools(this.deps.store(), false, this.deps.signalCollector, {
updateTokenBudget: (context, input) => this.updateTokenBudget(context, input),
});
// Goals are created by the user (`/goal` / REST), never by the model, so
// a session without a goal record has nothing for the model to read or
// update. Skip the ~800-token tool schemas in that case.
const goal = await this.deps.store().getBySession(sessionId);
if (!goal) return [];
const tools = buildThreadGoalRuntimeTools(
this.deps.store(),
false,
this.deps.signalCollector,
{ updateTokenBudget: (context, input) => this.updateTokenBudget(context, input) },
);
// A completed goal can still be inspected but no longer updated.
return goal.status === 'complete'
? tools.filter((tool) => tool.def.name === 'get_goal')
: tools;
}

tallyTurnUsage(messages: PiAgentMessage[]): number {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { resolveAgentCapabilities } from "@mavis/config";
import { describe, expect, it } from "vitest";

import {
hasManagedAccountSession,
resolveFeatureAwareBuiltinSkillNames,
} from "../../src/agent/feature-owned-skills.js";

const gates = { cuModeActive: false };

describe("website deploy skill gating", () => {
const capabilities = resolveAgentCapabilities();

it("keeps the deploy skills when the capability config alone decides", () => {
const names = resolveFeatureAwareBuiltinSkillNames(capabilities, gates);
expect(names).toContain("deploy-website");
expect(names).toContain("edit-deployed-website");
});

it("keeps the deploy skills when website_deploy is wired", () => {
const names = resolveFeatureAwareBuiltinSkillNames(capabilities, {
...gates,
websiteDeployAvailable: true,
});
expect(names).toContain("deploy-website");
expect(names).toContain("edit-deployed-website");
});

it("drops the deploy skills when website_deploy is not wired", () => {
const names = resolveFeatureAwareBuiltinSkillNames(capabilities, {
...gates,
websiteDeployAvailable: false,
});
expect(names).not.toContain("deploy-website");
expect(names).not.toContain("edit-deployed-website");
expect(names).toContain("code-review");
});
});

describe("hasManagedAccountSession", () => {
it("requires a non-blank access token", () => {
expect(hasManagedAccountSession(undefined)).toBe(false);
expect(hasManagedAccountSession({})).toBe(false);
expect(hasManagedAccountSession({ accessToken: " " })).toBe(false);
expect(hasManagedAccountSession({ accessToken: "tok" })).toBe(true);
});
});
30 changes: 30 additions & 0 deletions packages/local-runtime/test/unit/local-native-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,36 @@ describe('local native turn tools', () => {
expect(appendMemory).not.toHaveBeenCalled();
});

it.each([
['no auth context (API-key / custom-provider path)', undefined, false],
['an auth context without an access token', { userName: 'u' }, false],
['a blank access token', { accessToken: ' ' }, false],
['a MiniMax account access token', { accessToken: 'account-token' }, true],
])('only offers website_deploy (and its skills) with %s', async (_label, authContext, expected) => {
const dataDir = await mkdtemp(join(tmpdir(), 'local-native-website-deploy-'));
try {
const builtinCapabilities = resolveAgentCapabilities({});
const { nativeTools } = await buildLocalTurnToolSources({
toolsDisabled: false,
workspaceRoot: process.cwd(),
agentName: 'mavis',
dataDir,
sessionId: 'website-deploy-gate',
mcpService: new LocalMcpService(() => dataDir),
threadGoal: { runtimeToolsFor: () => [] } as never,
emitBusEvent: () => {},
builtinCapabilities,
...(authContext ? { authContext } : {}),
taskAdapter: {} as never,
taskControlAdapter: {} as never,
mavisAgentAdapter: {} as never,
});
expect(nativeTools.map((tool) => tool.def.name).includes('website_deploy')).toBe(expected);
} finally {
await rm(dataDir, { recursive: true, force: true });
}
});

it('filters configurable built-in tools and only the delegation entry point', async () => {
const dataDir = await mkdtemp(join(tmpdir(), 'local-native-capability-tools-'));
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";

import {
LOCAL_SKILL_DESCRIPTION_MAX_CODE_POINTS,
renderLocalSkillsCatalogResult,
} from "../../src/skills/catalog.js";

describe("local skills catalog description cap", () => {
it("caps ordinary skill descriptions at 300 code points", () => {
expect(LOCAL_SKILL_DESCRIPTION_MAX_CODE_POINTS).toBe(300);
const result = renderLocalSkillsCatalogResult([
{ name: "long-skill", description: "x".repeat(1_000), builtin: true },
]);
expect(result.descriptionCapTruncated).toBe(true);
expect(result.catalog).toContain("x".repeat(300));
expect(result.catalog).not.toContain("x".repeat(301));
});

it("keeps descriptions at the cap intact and never trims protected skills", () => {
const result = renderLocalSkillsCatalogResult([
{ name: "fits", description: "y".repeat(300), builtin: true },
{ name: "minimax-code-product", description: "p".repeat(900), builtin: true },
]);
expect(result.descriptionCapTruncated).toBe(false);
expect(result.catalog).toContain("y".repeat(300));
expect(result.catalog).toContain("p".repeat(900));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";

import type { ThreadGoalState, ThreadGoalStatus } from "@mavis/goal";

import { GoalLifecycle } from "../../../src/thread-goal/lifecycle.js";

function lifecycleWith(goal: Partial<ThreadGoalState> | undefined, enabled = true) {
const getBySession = vi.fn(async () => goal as ThreadGoalState | undefined);
const lifecycle = new GoalLifecycle({
runtime: { isEnabled: () => enabled },
store: () => ({ getBySession }),
signalCollector: { collect: () => "accepted" },
} as never);
return { lifecycle, getBySession };
}

async function names(lifecycle: GoalLifecycle): Promise<string[]> {
return (await lifecycle.runtimeToolsFor(false, "sess_a")).map((tool) => tool.def.name).sort();
}

describe("GoalLifecycle.runtimeToolsFor", () => {
it("exposes no goal tools when the session has no goal", async () => {
const { lifecycle, getBySession } = lifecycleWith(undefined);
expect(await names(lifecycle)).toEqual([]);
expect(getBySession).toHaveBeenCalledWith("sess_a");
});

it("exposes nothing when the feature is disabled, without reading the store", async () => {
const { lifecycle, getBySession } = lifecycleWith({ status: "active" }, false);
expect(await names(lifecycle)).toEqual([]);
expect(await lifecycle.runtimeToolsFor(true, "sess_a")).toEqual([]);
expect(getBySession).not.toHaveBeenCalled();
});

it("exposes only get_goal for a completed goal", async () => {
const { lifecycle } = lifecycleWith({ status: "complete" });
expect(await names(lifecycle)).toEqual(["get_goal"]);
});

it.each<ThreadGoalStatus>(["active", "paused", "budget_limited", "blocked", "usage_limited"])(
"exposes both tools for an unfinished %s goal",
async (status) => {
const { lifecycle } = lifecycleWith({ status });
expect(await names(lifecycle)).toEqual(["get_goal", "update_goal"]);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,19 @@ describe("LocalThreadGoalIntegration injected v2 Turn settlement", () => {
expect(active.status).toBe("active");
});

it.each(["active", "complete"] as const)(
it("does not expose update_goal for a complete Goal", async () => {
const { integration } = makeIntegration({
store: makeStore({ getBySession: async () => goal({ status: "complete" }) }),
});

const names = (await integration.runtimeToolsFor(false, "sess_int")).map(
(tool) => tool.def.name,
);

expect(names).toEqual(["get_goal"]);
});

it.each(["active"] as const)(
"rejects an unbound proposal without ending the user Turn or changing the %s Goal",
async (status) => {
const currentGoal = goal({ status });
Expand Down
3 changes: 3 additions & 0 deletions release/public-source.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions test/vitest-suites.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"packages/agent-tools/src/shared/replace-all-edit.test.ts",
"packages/agent-tools/src/shared/cron-schedule-input.test.ts",
"packages/agent-tools/src/desktop/edit-diff-bounds.test.ts",
"packages/local-runtime/test/unit/feature-owned-skills-website-deploy.test.ts",
"packages/local-runtime/test/unit/skills-catalog-description-cap.test.ts",
"packages/local-runtime/test/unit/thread-goal/goal-lifecycle-runtime-tools.test.ts",
"packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts",
"packages/local-runtime-v2/test/unit/agent/agent-import.test.ts",
"packages/tui/test/unit/auth-application.test.ts",
Expand Down
Loading