Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion docs/agents/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -619,16 +619,19 @@ tools:
remove:
- image_.*
- file_edit_.*
- task
- task_apply_git_patch
- task_list
- task_send_message
- task_terminate
- task_workspace_lifecycle
- workflow_run
- workflow_resume
---

You are in Explore mode (read-only).

You may use `task` to delegate independent read-only investigation only to `explore` agents. Do not delegate to other agents.

=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===

- You MUST NOT manually create, edit, delete, move, copy, or rename tracked files.
Expand Down
2 changes: 2 additions & 0 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export interface ToolConfiguration {
onConfigChanged?: () => void;
/** Best-effort callback for recording tool-initiated model usage in session totals. */
reportModelUsage?: (event: ToolModelUsageEvent) => void;
/** Restrict task delegation to read-only Explore agents and reject full workspace turns. */
taskExploreOnly?: boolean;
/** Task orchestration for sub-agent tasks */
taskService?: TaskService;
/** Durable workflow lifecycle service for dynamic workflow tools. */
Expand Down
5 changes: 4 additions & 1 deletion src/node/builtinAgents/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,19 @@ tools:
remove:
- image_.*
- file_edit_.*
- task
- task_apply_git_patch
- task_list
- task_send_message
- task_terminate
- task_workspace_lifecycle
- workflow_run
- workflow_resume
---

You are in Explore mode (read-only).

You may use `task` to delegate independent read-only investigation only to `explore` agents. Do not delegate to other agents.

=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===

- You MUST NOT manually create, edit, delete, move, copy, or rename tracked files.
Expand Down
2 changes: 1 addition & 1 deletion src/node/builtinSkills/deep-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ workflow_run({

Default to foreground mode because the user normally needs the final report before you can answer. If the user explicitly asks you to research in the background or be notified later, pass `run_in_background: true`, report the `runId`, and end the turn; Mux will wake the workspace with the terminal workflow result.

The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims adversarially with exec agents using their configured defaults, and synthesizes a cited report with caveats.
The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims with read-only agents, and synthesizes a cited report with caveats.
3 changes: 2 additions & 1 deletion src/node/builtinSkills/deep-research/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export default function workflow({ args, phase, log, agent, parallel, pipeline }
agent(buildVerifyPrompt(question, spec.claim, spec.voteIndex), {
id: stableId("verify", spec.claimIndex + "-" + spec.voteIndex, spec.claim.claim),
title: "Verify claim " + (spec.claimIndex + 1) + "." + (spec.voteIndex + 1),
agentId: EXEC_AGENT,
agentId: EXPLORE_AGENT,
onRefusal: "fail",
schema: VERDICT_SCHEMA,
})
Expand Down Expand Up @@ -368,6 +368,7 @@ function buildVerifyPrompt(question, claim, voteIndex) {
"Supporting quote: " + claim.quote,
"",
"Use `web_search` and, if needed, `web_fetch` to check contradiction, overreach, source quality, and staleness.",
"Do not start workflows or delegate this task. Use the available search and fetch tools directly.",
Comment thread
ethanndickson marked this conversation as resolved.
Outdated
"Set refuted=true if the quote does not support the claim, credible sources contradict it, the source is too weak, or the claim is stale/marketing. Use an empty string for counterSource if no counter-source exists. Return structured output only.",
].join("\n");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -495,10 +495,11 @@ Custom planning instructions.
"task_terminate",
"task_workspace_lifecycle",
"workflow_run",
"workflow_resume",
],
toolPolicy
)
).toEqual(["task_await", "workflow_run"]);
).toEqual(["task", "task_await"]);
});
test("same-name override: project agent with base: self extends built-in/global, not itself", async () => {
using project = new DisposableTempDir("agent-same-name");
Expand Down

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/node/services/agentDefinitions/builtInAgentDefinitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ describe("built-in agent definitions", () => {
expect(removed).not.toContain("agent_skill_read_file");
});

test("explore can delegate only through task without starting workflows", () => {
const pkgs = getBuiltInAgentDefinitions();
const byId = new Map(pkgs.map((pkg) => [pkg.id, pkg] as const));

const explore = byId.get("explore");
expect(explore).toBeTruthy();
const removed = explore?.frontmatter.tools?.remove ?? [];
expect(removed).not.toContain("task");
expect(removed).toContain("workflow_run");
expect(removed).toContain("workflow_resume");
});

test("analytics_query remains unavailable in general-purpose built-in agents", () => {
const pkgs = getBuiltInAgentDefinitions();
const byId = new Map(pkgs.map((pkg) => [pkg.id, pkg] as const));
Expand Down
10 changes: 7 additions & 3 deletions src/node/services/agentSkills/builtInSkillContent.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
"",
"Default to foreground mode because the user normally needs the final report before you can answer. If the user explicitly asks you to research in the background or be notified later, pass `run_in_background: true`, report the `runId`, and end the turn; Mux will wake the workspace with the terminal workflow result.",
"",
"The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims adversarially with exec agents using their configured defaults, and synthesizes a cited report with caveats.",
"The workflow scopes search angles, searches and fetches sources, extracts falsifiable claims, verifies claims with read-only agents, and synthesizes a cited report with caveats.",
"",
].join("\n"),
"workflow.js": [
Expand Down Expand Up @@ -427,7 +427,7 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
" agent(buildVerifyPrompt(question, spec.claim, spec.voteIndex), {",
' id: stableId("verify", spec.claimIndex + "-" + spec.voteIndex, spec.claim.claim),',
' title: "Verify claim " + (spec.claimIndex + 1) + "." + (spec.voteIndex + 1),',
" agentId: EXEC_AGENT,",
" agentId: EXPLORE_AGENT,",
' onRefusal: "fail",',
" schema: VERDICT_SCHEMA,",
" })",
Expand Down Expand Up @@ -530,6 +530,7 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
' "Supporting quote: " + claim.quote,',
' "",',
' "Use `web_search` and, if needed, `web_fetch` to check contradiction, overreach, source quality, and staleness.",',
' "Do not start workflows or delegate this task. Use the available search and fetch tools directly.",',
' "Set refuted=true if the quote does not support the claim, credible sources contradict it, the source is too weak, or the claim is stale/marketing. Use an empty string for counterSource if no counter-source exists. Return structured output only.",',
' ].join("\\n");',
"}",
Expand Down Expand Up @@ -2213,16 +2214,19 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
" remove:",
" - image_.*",
" - file_edit_.*",
" - task",
" - task_apply_git_patch",
" - task_list",
" - task_send_message",
" - task_terminate",
" - task_workspace_lifecycle",
" - workflow_run",
" - workflow_resume",
"---",
"",
"You are in Explore mode (read-only).",
"",
"You may use `task` to delegate independent read-only investigation only to `explore` agents. Do not delegate to other agents.",
"",
"=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===",
"",
"- You MUST NOT manually create, edit, delete, move, copy, or rename tracked files.",
Expand Down
1 change: 1 addition & 0 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2085,6 +2085,7 @@ export class AIService extends EventEmitter {
// Plan agent configuration for plan file access.
// - read: plan file is readable in all agents (useful context)
// - write: allowed in all agents; plan agents still lock other edits to the exact plan path
taskExploreOnly: effectiveAgentId === "explore",
Comment thread
ethanndickson marked this conversation as resolved.
Outdated
planFileOnly: agentIsPlanLike,
emitChatEvent: (event) => {
// Defensive: tools should only emit events for the workspace they belong to.
Expand Down
94 changes: 94 additions & 0 deletions src/node/services/tools/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,100 @@ describe("task tool", () => {
}
});

it("allows Explore agents to spawn Explore tasks", async () => {
using tempDir = new TestTempDir("test-task-tool-explore-child");
const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" });
const create = mock(() =>
Ok({ taskId: "child-task", kind: "agent" as const, status: "running" as const })
);
const taskService = { create } as unknown as TaskService;
const tool = createTaskTool({
...baseConfig,
taskExploreOnly: true,
taskService,
});

await Promise.resolve(
tool.execute!(
{
agentId: "explore",
prompt: "inspect one read-only slice",
title: "Inspect slice",
run_in_background: true,
},
mockToolCallOptions
)
);

expect(create).toHaveBeenCalledTimes(1);
const createCall = create.mock.calls[0] as unknown[];
expect(createCall[0]).toMatchObject({ agentId: "explore" });
expect(tool.description).toContain('only spawn agentId: "explore" tasks');
});

it("rejects non-Explore tasks and workspace turns from Explore agents", async () => {
using tempDir = new TestTempDir("test-task-tool-explore-restriction");
const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" });
const create = mock(() =>
Ok({ taskId: "child-task", kind: "agent" as const, status: "running" as const })
);
const createWorkspaceTurn = mock(() =>
Ok({
taskId: "wst_child-turn",
kind: "workspace_turn" as const,
status: "running" as const,
workspaceId: "child-workspace",
})
);
const taskService = { create, createWorkspaceTurn } as unknown as TaskService;
const tool = createTaskTool({
...baseConfig,
taskExploreOnly: true,
taskService,
});

let taskError: unknown;
try {
await Promise.resolve(
tool.execute!(
{
agentId: "exec",
prompt: "make a change",
title: "Change code",
run_in_background: true,
},
mockToolCallOptions
)
);
} catch (error: unknown) {
taskError = error;
}
expect(taskError).toBeInstanceOf(Error);
expect((taskError as Error).message).toMatch(/Explore agent.*only spawn.*explore/i);

let workspaceError: unknown;
try {
await Promise.resolve(
tool.execute!(
{
kind: "workspace",
prompt: "start another workspace",
title: "Workspace",
run_in_background: true,
},
mockToolCallOptions
)
);
} catch (error: unknown) {
workspaceError = error;
}
expect(workspaceError).toBeInstanceOf(Error);
expect((workspaceError as Error).message).toMatch(/Explore agent.*only spawn.*explore/i);

expect(create).not.toHaveBeenCalled();
expect(createWorkspaceTurn).not.toHaveBeenCalled();
});

it("should reject workspace turns while in plan agent", async () => {
using tempDir = new TestTempDir("test-task-tool-plan-workspace");
const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" });
Expand Down
17 changes: 15 additions & 2 deletions src/node/services/tools/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ import { coerceNonEmptyString } from "@/node/services/taskUtils";
const PLAN_AGENT_EXPLORE_ONLY_ERROR =
'In the plan agent you may only spawn agentId: "explore" tasks.';

const EXPLORE_AGENT_EXPLORE_ONLY_ERROR =
'The Explore agent may only spawn agentId: "explore" tasks.';

const BUILT_IN_TASK_TOOL_MARKER = Symbol("muxBuiltInTaskTool");

export function markBuiltInTaskTool<TParameters, TResult>(
Expand Down Expand Up @@ -80,16 +83,20 @@ function buildTaskDescription(config: ToolConfiguration): string {
const baseDescription = buildTaskToolDescription(runtimeMode);
const subagents = config.availableSubagents?.filter((a) => a.subagentRunnable) ?? [];

const restriction = config.taskExploreOnly
? '\n\nThis agent may only spawn agentId: "explore" tasks. Full workspace turns are not allowed.'
: "";

if (subagents.length === 0) {
return baseDescription;
return baseDescription + restriction;
}

const subagentLines = subagents.map((agent) => {
const desc = agent.description ? `: ${agent.description}` : "";
return `- ${agent.id}${desc}`;
});

return `${baseDescription}\n\nAvailable sub-agents (use \`agentId\` parameter):\n${subagentLines.join("\n")}`;
return `${baseDescription}\n\nAvailable sub-agents (use \`agentId\` parameter):\n${subagentLines.join("\n")}${restriction}`;
}

function buildParentRuntimeAiSettings(
Expand Down Expand Up @@ -431,6 +438,9 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => {
if (config.planFileOnly && kind === "workspace") {
throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR);
}
if (config.taskExploreOnly && kind === "workspace") {
throw new Error(EXPLORE_AGENT_EXPLORE_ONLY_ERROR);
}

if (kind === "workspace") {
const created = await taskService.createWorkspaceTurn({
Expand Down Expand Up @@ -545,6 +555,9 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => {
if (config.planFileOnly && requestedAgentId !== "explore") {
throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR);
}
if (config.taskExploreOnly && requestedAgentId !== "explore") {
throw new Error(EXPLORE_AGENT_EXPLORE_ONLY_ERROR);
}

// Parent runtime model and thinking are forwarded as a low-priority fallback so
// unconfigured delegated runs still inherit the parent's live model. Do not
Expand Down
Loading
Loading