src / tools / agentTool.ts
src / tools / agentTool.ts
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { hashRunSpec, validateSpec } from "../agents/orchestrator";
import type { AgentRunState } from "../agents/runStore";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import {
applyGate,
compact,
consumeApproval,
hashOperation,
type GateOutcome,
} from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
function stateData(runtime: PluginRuntime, state: AgentRunState) {
return {
id: state.id,
status: state.status,
objective: state.objective,
role: state.role,
modelId: state.modelId,
mode: state.mode,
commitEdits: state.commitEdits,
allowCommands: state.allowCommands,
allowWeb: state.allowWeb,
taskBoardId: state.taskBoardId,
researchProjectId: state.researchProjectId,
pass: state.pass,
maxPasses: state.maxPasses,
toolCalls: state.toolCalls,
maxToolCalls: state.maxToolCalls,
plan: state.plan,
filesRead: state.filesRead,
filesChanged: state.filesChanged,
transactions: state.transactions,
commands: state.commands,
sources: state.sources,
notes: state.notes,
final: state.final,
error: state.error,
createdAt: state.createdAt,
updatedAt: state.updatedAt,
startedAt: state.startedAt,
finishedAt: state.finishedAt,
statePath: runtime.runStore.stateRelativePath(state.id),
transcriptPath: runtime.runStore.transcriptRelativePath(state.id),
};
}
function stateSummaryData(runtime: PluginRuntime, state: AgentRunState) {
return {
id: state.id,
status: state.status,
objective: state.objective.slice(0, 500),
role: state.role,
mode: state.mode,
commitEdits: state.commitEdits,
allowWeb: state.allowWeb,
taskBoardId: state.taskBoardId,
researchProjectId: state.researchProjectId,
pass: state.pass,
maxPasses: state.maxPasses,
toolCalls: state.toolCalls,
maxToolCalls: state.maxToolCalls,
fileCount: state.filesChanged.length,
filesChanged: state.filesChanged.slice(0, 20),
transactionCount: state.transactions.length,
commandCount: state.commands.length,
sourceCount: state.sources.length,
finalSummary: state.final?.summary,
error: state.error,
updatedAt: state.updatedAt,
statePath: runtime.runStore.stateRelativePath(state.id),
transcriptPath: runtime.runStore.transcriptRelativePath(state.id),
};
}
function facts(state: AgentRunState): string[] {
return [
`agent run ${state.id} ${state.status}`,
`agent mode ${state.mode}`,
...(state.taskBoardId ? [`task board ${state.taskBoardId}`] : []),
...(state.researchProjectId ? [`research project ${state.researchProjectId}`] : []),
...(state.final ? [state.final.summary, ...state.final.evidence] : []),
...state.filesChanged.map((path) => `changed ${path}`),
].slice(0, 40);
}
export function createAgentTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_agent",
description:
"Run a durable pass-based sub-agent. Actions: run, start, status, history, cancel, list. Delegate only a bounded sub-task you can state in one objective; do the main work yourself. State persists under .agentic/runs/. In Manual mode runs start with commit_edits=false and are staged only when commands or web are on: run/start return pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["run", "start", "status", "history", "cancel", "list"]),
objective: z.string().optional(),
context: z.string().optional(),
role: z.string().optional(),
model_id: z.string().optional(),
mode: z.enum(["coding", "research", "general"]).optional(),
commit_edits: z
.boolean()
.optional()
.describe("Only lowers the mode-derived default; true never raises it."),
allow_commands: z.boolean().optional(),
allow_web: z.boolean().optional(),
task_board_id: z.string().optional(),
research_project_id: z.string().optional(),
max_passes: z.number().int().min(1).max(20).optional(),
rounds_per_pass: z.number().int().min(1).max(12).optional(),
max_tool_calls: z.number().int().min(1).max(200).optional(),
idempotency_key: z.string().optional(),
run_id: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action: "run" | "start" | "status" | "history" | "cancel" | "list";
objective?: string;
context?: string;
role?: string;
model_id?: string;
mode?: "coding" | "research" | "general";
commit_edits?: boolean;
allow_commands?: boolean;
allow_web?: boolean;
task_board_id?: string;
research_project_id?: string;
max_passes?: number;
rounds_per_pass?: number;
max_tool_calls?: number;
idempotency_key?: string;
run_id?: string;
limit?: number;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.internalAgentsEnabled) {
throw new AgenticError("AGENT_DISABLED", "Durable sub-agents are disabled.");
}
if (input.action === "run" || input.action === "start") {
if (!input.objective) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_agent ${input.action} requires objective.`,
);
}
const mode = runtime.settings.permissionMode;
const commandsWanted = input.allow_commands ?? input.mode !== "research";
const commandsEnabled = runtime.settings.agentCommandsEnabled && commandsWanted;
// The run's effective web access, computed exactly as the
// orchestrator computes it (AgentOrchestratorOptions.webEnabled &&
// spec.allowWeb): the model may ask for it, the config sets the
// ceiling.
const webWanted = input.allow_web ?? true;
const webEnabled =
runtime.settings.webResearchEnabled && runtime.settings.agentAllowWeb && webWanted;
const spec = {
objective: input.objective,
context: input.context,
role: input.role,
modelId: input.model_id,
mode: input.mode,
allowCommands: input.allow_commands,
allowWeb: input.allow_web,
taskBoardId: input.task_board_id,
researchProjectId: input.research_project_id,
maxPasses: input.max_passes,
roundsPerPass: input.rounds_per_pass,
maxToolCalls: input.max_tool_calls,
idempotencyKey: input.idempotency_key,
};
// Validate before gating: an over-long objective or context is an
// INVALID_INPUT, not something to stage for approval.
validateSpec(spec);
// The permission mode decides whether the run may commit its own
// edits. In plan mode the run itself needs an approved plan; in
// manual mode it is staged whenever it could reach outside the
// workspace — commands or web — because those are the two effects
// the user cannot take back afterwards. Edits alone are already held
// for the user because commitEdits is false.
let commitEdits = mode === "auto";
let gate: GateOutcome | undefined;
const gateRequest = {
kind: "agent" as const,
operation: `agent.${input.action}`,
operationHash: hashOperation(
"agent",
hashRunSpec({ ...spec, commitEdits: undefined }),
),
title: `Run sub-agent: ${input.objective.slice(0, 120)}`,
description: `mode ${input.mode ?? "coding"}; commands ${
commandsEnabled ? "enabled" : "disabled"
}; web ${webEnabled ? "enabled" : "disabled"}`,
destructive: false,
reference: {},
resume: { tool: "workspace_agent", arguments: compact({ ...input }) },
};
if (mode === "plan") {
gate = await applyGate(runtime, ctx, gateRequest);
commitEdits = true;
} else if (mode === "manual") {
commitEdits = false;
// A run with `allow_web: true` and commands disabled still owns
// web_search/fetch_source inside the run, so gating on commands
// alone left outbound egress unstaged while every direct
// workspace_research call was being staged. The shortcut survives
// only for a run that can do neither.
if (commandsEnabled || webEnabled) gate = await applyGate(runtime, ctx, gateRequest);
}
// In plan mode applyGate throws APPROVAL_REQUIRED rather than staging;
// this single check covers both branches should that ever change.
if (gate?.outcome === "stage") return gate.result;
if (input.commit_edits === false) commitEdits = false;
const started = await runtime.agents.start({ ...spec, commitEdits });
if (gate) await consumeApproval(runtime, ctx, gate, { runId: started.state.id });
const state = input.action === "run" ? await started.promise : started.state;
return okResult({
operation: `agent.${input.action}`,
summary:
input.action === "start"
? `Agent run ${state.id} started. Poll with workspace_agent status.`
: `Agent run ${state.id} finished with status ${state.status}: ${
state.final?.summary ?? state.error ?? "no final summary"
}`,
data: stateData(runtime, state),
importance: state.status === "completed" ? "critical" : "high",
facts: facts(state),
omit: ["data.notes", "data.plan"],
});
}
if (input.action === "list") {
const states = await runtime.agents.list(input.limit ?? 20);
return okResult({
operation: "agent.list",
summary: `Listed ${states.length} agent run(s).`,
data: states.map((state) => stateSummaryData(runtime, state)),
importance: "high",
facts: states.slice(0, 10).map((state) => `${state.id} ${state.status}`),
});
}
if (!input.run_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_agent ${input.action} requires run_id.`,
);
}
if (input.action === "history") {
const history = await runtime.agents.history(input.run_id, input.limit ?? 50);
return okResult({
operation: "agent.history",
summary: `Loaded ${history.events.length} of ${history.total} event(s) for ${input.run_id}.`,
data: history,
facts: history.events.slice(-10).map((event) => event.summary),
omit: ["data.events"],
});
}
const state =
input.action === "cancel"
? await runtime.agents.cancel(input.run_id)
: await runtime.agents.status(input.run_id);
return okResult({
operation: `agent.${input.action}`,
summary: `${state.id}: ${state.status}${
state.final ? ` — ${state.final.summary}` : state.error ? ` — ${state.error}` : ""
}`,
data: stateData(runtime, state),
importance: state.status === "completed" ? "critical" : "high",
facts: facts(state),
omit: ["data.notes", "data.plan"],
});
} catch (error) {
return errorResult(`agent.${input.action}`, error, "high");
}
},
});
}
import { tool, type Tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
import { hashRunSpec, validateSpec } from "../agents/orchestrator";
import type { AgentRunState } from "../agents/runStore";
import { AgenticError } from "../core/errors";
import { errorResult, okResult } from "../core/result";
import {
applyGate,
compact,
consumeApproval,
hashOperation,
type GateOutcome,
} from "../policy/gateRuntime";
import type { PluginRuntime } from "../runtime";
function stateData(runtime: PluginRuntime, state: AgentRunState) {
return {
id: state.id,
status: state.status,
objective: state.objective,
role: state.role,
modelId: state.modelId,
mode: state.mode,
commitEdits: state.commitEdits,
allowCommands: state.allowCommands,
allowWeb: state.allowWeb,
taskBoardId: state.taskBoardId,
researchProjectId: state.researchProjectId,
pass: state.pass,
maxPasses: state.maxPasses,
toolCalls: state.toolCalls,
maxToolCalls: state.maxToolCalls,
plan: state.plan,
filesRead: state.filesRead,
filesChanged: state.filesChanged,
transactions: state.transactions,
commands: state.commands,
sources: state.sources,
notes: state.notes,
final: state.final,
error: state.error,
createdAt: state.createdAt,
updatedAt: state.updatedAt,
startedAt: state.startedAt,
finishedAt: state.finishedAt,
statePath: runtime.runStore.stateRelativePath(state.id),
transcriptPath: runtime.runStore.transcriptRelativePath(state.id),
};
}
function stateSummaryData(runtime: PluginRuntime, state: AgentRunState) {
return {
id: state.id,
status: state.status,
objective: state.objective.slice(0, 500),
role: state.role,
mode: state.mode,
commitEdits: state.commitEdits,
allowWeb: state.allowWeb,
taskBoardId: state.taskBoardId,
researchProjectId: state.researchProjectId,
pass: state.pass,
maxPasses: state.maxPasses,
toolCalls: state.toolCalls,
maxToolCalls: state.maxToolCalls,
fileCount: state.filesChanged.length,
filesChanged: state.filesChanged.slice(0, 20),
transactionCount: state.transactions.length,
commandCount: state.commands.length,
sourceCount: state.sources.length,
finalSummary: state.final?.summary,
error: state.error,
updatedAt: state.updatedAt,
statePath: runtime.runStore.stateRelativePath(state.id),
transcriptPath: runtime.runStore.transcriptRelativePath(state.id),
};
}
function facts(state: AgentRunState): string[] {
return [
`agent run ${state.id} ${state.status}`,
`agent mode ${state.mode}`,
...(state.taskBoardId ? [`task board ${state.taskBoardId}`] : []),
...(state.researchProjectId ? [`research project ${state.researchProjectId}`] : []),
...(state.final ? [state.final.summary, ...state.final.evidence] : []),
...state.filesChanged.map((path) => `changed ${path}`),
].slice(0, 40);
}
export function createAgentTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_agent",
description:
"Run a durable pass-based sub-agent. Actions: run, start, status, history, cancel, list. Delegate only a bounded sub-task you can state in one objective; do the main work yourself. State persists under .agentic/runs/. In Manual mode runs start with commit_edits=false and are staged only when commands or web are on: run/start return pending_approval + an id, report it and stop, re-issue after /accept. Plan mode without an approved plan fails APPROVAL_REQUIRED.",
parameters: {
action: z.enum(["run", "start", "status", "history", "cancel", "list"]),
objective: z.string().optional(),
context: z.string().optional(),
role: z.string().optional(),
model_id: z.string().optional(),
mode: z.enum(["coding", "research", "general"]).optional(),
commit_edits: z
.boolean()
.optional()
.describe("Only lowers the mode-derived default; true never raises it."),
allow_commands: z.boolean().optional(),
allow_web: z.boolean().optional(),
task_board_id: z.string().optional(),
research_project_id: z.string().optional(),
max_passes: z.number().int().min(1).max(20).optional(),
rounds_per_pass: z.number().int().min(1).max(12).optional(),
max_tool_calls: z.number().int().min(1).max(200).optional(),
idempotency_key: z.string().optional(),
run_id: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action: "run" | "start" | "status" | "history" | "cancel" | "list";
objective?: string;
context?: string;
role?: string;
model_id?: string;
mode?: "coding" | "research" | "general";
commit_edits?: boolean;
allow_commands?: boolean;
allow_web?: boolean;
task_board_id?: string;
research_project_id?: string;
max_passes?: number;
rounds_per_pass?: number;
max_tool_calls?: number;
idempotency_key?: string;
run_id?: string;
limit?: number;
}, ctx: ToolCallContext) => {
try {
if (!runtime.settings.internalAgentsEnabled) {
throw new AgenticError("AGENT_DISABLED", "Durable sub-agents are disabled.");
}
if (input.action === "run" || input.action === "start") {
if (!input.objective) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_agent ${input.action} requires objective.`,
);
}
const mode = runtime.settings.permissionMode;
const commandsWanted = input.allow_commands ?? input.mode !== "research";
const commandsEnabled = runtime.settings.agentCommandsEnabled && commandsWanted;
// The run's effective web access, computed exactly as the
// orchestrator computes it (AgentOrchestratorOptions.webEnabled &&
// spec.allowWeb): the model may ask for it, the config sets the
// ceiling.
const webWanted = input.allow_web ?? true;
const webEnabled =
runtime.settings.webResearchEnabled && runtime.settings.agentAllowWeb && webWanted;
const spec = {
objective: input.objective,
context: input.context,
role: input.role,
modelId: input.model_id,
mode: input.mode,
allowCommands: input.allow_commands,
allowWeb: input.allow_web,
taskBoardId: input.task_board_id,
researchProjectId: input.research_project_id,
maxPasses: input.max_passes,
roundsPerPass: input.rounds_per_pass,
maxToolCalls: input.max_tool_calls,
idempotencyKey: input.idempotency_key,
};
// Validate before gating: an over-long objective or context is an
// INVALID_INPUT, not something to stage for approval.
validateSpec(spec);
// The permission mode decides whether the run may commit its own
// edits. In plan mode the run itself needs an approved plan; in
// manual mode it is staged whenever it could reach outside the
// workspace — commands or web — because those are the two effects
// the user cannot take back afterwards. Edits alone are already held
// for the user because commitEdits is false.
let commitEdits = mode === "auto";
let gate: GateOutcome | undefined;
const gateRequest = {
kind: "agent" as const,
operation: `agent.${input.action}`,
operationHash: hashOperation(
"agent",
hashRunSpec({ ...spec, commitEdits: undefined }),
),
title: `Run sub-agent: ${input.objective.slice(0, 120)}`,
description: `mode ${input.mode ?? "coding"}; commands ${
commandsEnabled ? "enabled" : "disabled"
}; web ${webEnabled ? "enabled" : "disabled"}`,
destructive: false,
reference: {},
resume: { tool: "workspace_agent", arguments: compact({ ...input }) },
};
if (mode === "plan") {
gate = await applyGate(runtime, ctx, gateRequest);
commitEdits = true;
} else if (mode === "manual") {
commitEdits = false;
// A run with `allow_web: true` and commands disabled still owns
// web_search/fetch_source inside the run, so gating on commands
// alone left outbound egress unstaged while every direct
// workspace_research call was being staged. The shortcut survives
// only for a run that can do neither.
if (commandsEnabled || webEnabled) gate = await applyGate(runtime, ctx, gateRequest);
}
// In plan mode applyGate throws APPROVAL_REQUIRED rather than staging;
// this single check covers both branches should that ever change.
if (gate?.outcome === "stage") return gate.result;
if (input.commit_edits === false) commitEdits = false;
const started = await runtime.agents.start({ ...spec, commitEdits });
if (gate) await consumeApproval(runtime, ctx, gate, { runId: started.state.id });
const state = input.action === "run" ? await started.promise : started.state;
return okResult({
operation: `agent.${input.action}`,
summary:
input.action === "start"
? `Agent run ${state.id} started. Poll with workspace_agent status.`
: `Agent run ${state.id} finished with status ${state.status}: ${
state.final?.summary ?? state.error ?? "no final summary"
}`,
data: stateData(runtime, state),
importance: state.status === "completed" ? "critical" : "high",
facts: facts(state),
omit: ["data.notes", "data.plan"],
});
}
if (input.action === "list") {
const states = await runtime.agents.list(input.limit ?? 20);
return okResult({
operation: "agent.list",
summary: `Listed ${states.length} agent run(s).`,
data: states.map((state) => stateSummaryData(runtime, state)),
importance: "high",
facts: states.slice(0, 10).map((state) => `${state.id} ${state.status}`),
});
}
if (!input.run_id) {
throw new AgenticError(
"INVALID_INPUT",
`workspace_agent ${input.action} requires run_id.`,
);
}
if (input.action === "history") {
const history = await runtime.agents.history(input.run_id, input.limit ?? 50);
return okResult({
operation: "agent.history",
summary: `Loaded ${history.events.length} of ${history.total} event(s) for ${input.run_id}.`,
data: history,
facts: history.events.slice(-10).map((event) => event.summary),
omit: ["data.events"],
});
}
const state =
input.action === "cancel"
? await runtime.agents.cancel(input.run_id)
: await runtime.agents.status(input.run_id);
return okResult({
operation: `agent.${input.action}`,
summary: `${state.id}: ${state.status}${
state.final ? ` — ${state.final.summary}` : state.error ? ` — ${state.error}` : ""
}`,
data: stateData(runtime, state),
importance: state.status === "completed" ? "critical" : "high",
facts: facts(state),
omit: ["data.notes", "data.plan"],
});
} catch (error) {
return errorResult(`agent.${input.action}`, error, "high");
}
},
});
}