src / agents / orchestrator.ts
src / agents / orchestrator.ts
import { createHash } from "node:crypto";
import {
Chat,
type ChatMessage,
type LMStudioClient,
type LLM,
type Tool,
} from "@lmstudio/sdk";
import { AgenticError, invalidToolRequestReply, isAbortError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { createId } from "../core/id";
import type { Journal } from "../core/journal";
import { boundedPreview } from "../core/result";
import type { ProcessService } from "../execution/processRunner";
import type { ResearchProject, ResearchStore } from "../research/store";
import type { SearchProvider, WebResearchService } from "../research/web";
import type { TaskBoardStore } from "../tasks/taskStore";
import type { WorkspaceBoundary } from "../workspace/boundary";
import type { TransactionManager } from "../workspace/transactions";
import {
AgentRunStore,
type AgentRunMode,
type AgentRunState,
} from "./runStore";
import { toolBudgetExhaustedWithoutCompletion } from "./budget";
import { buildAgentTools } from "./tools";
import { unique } from "./tools/context";
export interface AgentRunSpec {
objective: string;
context?: string;
role?: string;
modelId?: string;
mode?: AgentRunMode;
commitEdits?: boolean;
allowCommands?: boolean;
allowWeb?: boolean;
taskBoardId?: string;
researchProjectId?: string;
maxPasses?: number;
roundsPerPass?: number;
maxToolCalls?: number;
idempotencyKey?: string;
}
export interface AgentOrchestratorOptions {
defaultModelId?: string;
defaultCommitEdits: boolean;
destructiveEditsEnabled: boolean;
commandsEnabled: boolean;
webEnabled: boolean;
defaultSearchProvider: SearchProvider;
maxPasses: number;
roundsPerPass: number;
maxToolCalls: number;
maxReadBytes: number;
maxResultChars: number;
}
interface ActiveRun {
workspaceRoot: string;
specHash: string;
controller: AbortController;
promise: Promise<AgentRunState>;
}
const activeRuns = new Map<string, ActiveRun>();
const runStartLocks = new Map<string, Promise<void>>();
function activeRunKey(workspaceRoot: string, id: string): string {
return `${workspaceRoot}\0${id}`;
}
async function withRunStartLock<T>(id: string, action: () => Promise<T>): Promise<T> {
const previous = runStartLocks.get(id) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
runStartLocks.set(id, queued);
await previous;
try {
return await action();
} finally {
release();
if (runStartLocks.get(id) === queued) runStartLocks.delete(id);
}
}
function runIdFor(spec: AgentRunSpec): string {
if (spec.idempotencyKey) {
return `run_${createHash("sha256")
.update(`agentic-workspace/v1:${spec.idempotencyKey}`)
.digest("hex")
.slice(0, 24)}`;
}
return createId("run");
}
/** Shape limits on a run request. Tools call this before gating so an invalid request is never staged. */
export function validateSpec(spec: AgentRunSpec): void {
if (!spec.objective?.trim()) {
throw new AgenticError("INVALID_INPUT", "Agent objective may not be empty.");
}
if (spec.objective.length > 20_000) {
throw new AgenticError("INVALID_INPUT", "Agent objective is limited to 20,000 characters.");
}
if ((spec.context?.length ?? 0) > 100_000) {
throw new AgenticError("INVALID_INPUT", "Agent context is limited to 100,000 characters.");
}
}
export function hashRunSpec(spec: AgentRunSpec): string {
return sha256Text(
JSON.stringify({
objective: spec.objective.trim(),
context: spec.context?.trim() || undefined,
role: spec.role?.trim() || undefined,
modelId: spec.modelId?.trim() || undefined,
mode: spec.mode,
commitEdits: spec.commitEdits,
allowCommands: spec.allowCommands,
allowWeb: spec.allowWeb,
taskBoardId: spec.taskBoardId,
researchProjectId: spec.researchProjectId,
maxPasses: spec.maxPasses,
roundsPerPass: spec.roundsPerPass,
maxToolCalls: spec.maxToolCalls,
idempotencyKey: spec.idempotencyKey,
}),
);
}
function compactText(value: string, limit = 1200): string {
return boundedPreview(value.trim(), limit).preview;
}
/**
* Renders the most recent `limit` values as prompt bullets, saying so when the
* list is longer. Every accumulating section of the pass prompt is a tail, not
* a full history: the prompt is rebuilt from durable state on every pass, so an
* unsliced section makes each pass more expensive than the last.
*/
function bulletTail(values: string[], limit: number): string {
if (values.length === 0) return "- None";
const shown = values.slice(-limit);
const header =
shown.length < values.length
? `- (most recent ${shown.length} of ${values.length})\n`
: "";
return `${header}${shown.map((value) => `- ${value}`).join("\n")}`;
}
export class AgentOrchestrator {
public constructor(
private readonly client: LMStudioClient,
private readonly boundary: WorkspaceBoundary,
private readonly store: AgentRunStore,
private readonly transactions: TransactionManager,
private readonly processes: ProcessService,
private readonly tasks: TaskBoardStore,
private readonly web: WebResearchService,
private readonly research: ResearchStore,
private readonly journal: Journal,
private readonly options: AgentOrchestratorOptions,
) {}
public async start(spec: AgentRunSpec): Promise<{
state: AgentRunState;
promise: Promise<AgentRunState>;
}> {
validateSpec(spec);
const id = runIdFor(spec);
if (!spec.idempotencyKey) return await this.startUnlocked(spec, id);
const key = activeRunKey(this.boundary.realRoot, id);
return await withRunStartLock(key, async () => await this.startUnlocked(spec, id));
}
private async startUnlocked(
spec: AgentRunSpec,
id: string,
): Promise<{ state: AgentRunState; promise: Promise<AgentRunState> }> {
await this.store.initialize();
const specHash = hashRunSpec(spec);
const runKey = activeRunKey(this.boundary.realRoot, id);
const active = activeRuns.get(runKey);
if (active) {
if (active.workspaceRoot !== this.boundary.root) {
throw new AgenticError("EDIT_CONFLICT", "Agent run id belongs to another workspace.");
}
if (active.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The agent idempotency key is already active with a different run specification.",
);
}
return { state: await this.store.load(id), promise: active.promise };
}
try {
const existing = await this.store.load(id);
if (existing.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The agent idempotency key was already used with a different run specification.",
);
}
if (existing.status === "running" || existing.status === "queued") {
existing.status = "orphaned";
existing.finishedAt = new Date().toISOString();
existing.error = "Plugin restarted while the run was active; execution ownership was lost.";
await this.store.save(existing);
}
return { state: existing, promise: Promise.resolve(existing) };
} catch (error) {
if (!(error instanceof AgenticError) || error.code !== "NOT_FOUND") throw error;
}
const mode: AgentRunMode = spec.mode ?? "coding";
const taskBoardId = spec.taskBoardId?.trim() || undefined;
if (taskBoardId) await this.tasks.load(taskBoardId);
let researchProjectId = spec.researchProjectId?.trim() || undefined;
let linkedResearch = researchProjectId
? await this.research.load(researchProjectId)
: undefined;
if (mode === "research" && !linkedResearch) {
linkedResearch = await this.research.create({
title: spec.objective.trim().slice(0, 180),
objective: spec.objective.trim(),
});
researchProjectId = linkedResearch.id;
}
const now = new Date().toISOString();
const state: AgentRunState = {
version: 1,
id,
specHash,
...(spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {}),
status: "queued",
objective: spec.objective.trim(),
...(spec.context?.trim() ? { context: spec.context.trim() } : {}),
role:
spec.role?.trim() ||
(mode === "research"
? "deep research agent"
: mode === "general"
? "autonomous execution agent"
: "software engineering agent"),
...(spec.modelId?.trim() || this.options.defaultModelId
? { modelId: spec.modelId?.trim() || this.options.defaultModelId }
: {}),
mode,
commitEdits: spec.commitEdits ?? this.options.defaultCommitEdits,
allowCommands:
(spec.allowCommands ?? mode !== "research") && this.options.commandsEnabled,
allowWeb: (spec.allowWeb ?? true) && this.options.webEnabled,
...(taskBoardId ? { taskBoardId } : {}),
...(researchProjectId ? { researchProjectId } : {}),
createdAt: now,
updatedAt: now,
pass: 0,
maxPasses: Math.max(1, Math.min(spec.maxPasses ?? this.options.maxPasses, 20)),
roundsPerPass: Math.max(
1,
Math.min(spec.roundsPerPass ?? this.options.roundsPerPass, 12),
),
toolCalls: 0,
maxToolCalls: Math.max(
1,
Math.min(spec.maxToolCalls ?? this.options.maxToolCalls, 200),
),
plan: [],
filesRead: [],
filesChanged: [],
transactions: [],
commands: [],
sources: (linkedResearch?.sources ?? []).map((source) => ({
id: source.id,
url: source.url,
...(source.title ? { title: source.title } : {}),
contentPath: source.contentPath,
sha256: source.sha256,
})),
notes: [],
recentEvents: [],
};
await this.store.create(state);
if (researchProjectId) await this.research.attachAgent(researchProjectId, id);
const controller = new AbortController();
const promise = this.execute(state, controller.signal).finally(() => {
activeRuns.delete(runKey);
});
activeRuns.set(runKey, {
workspaceRoot: this.boundary.root,
specHash,
controller,
promise,
});
return { state, promise };
}
public async run(spec: AgentRunSpec): Promise<AgentRunState> {
return await (await this.start(spec)).promise;
}
public async status(id: string): Promise<AgentRunState> {
const state = await this.store.load(id);
const active = activeRuns.get(activeRunKey(this.boundary.realRoot, id));
if (
(state.status === "running" || state.status === "queued") &&
(!active || active.workspaceRoot !== this.boundary.root)
) {
state.status = "orphaned";
state.finishedAt = new Date().toISOString();
state.error = "Plugin restarted while the run was active; execution ownership was lost.";
await this.store.save(state);
}
return state;
}
public async list(limit = 20): Promise<AgentRunState[]> {
const states = await this.store.list(limit);
return await Promise.all(
states.map(async (state) =>
state.status === "running" || state.status === "queued"
? await this.status(state.id)
: state,
),
);
}
public async history(id: string, limit?: number) {
return await this.store.history(id, limit);
}
public async cancel(id: string): Promise<AgentRunState> {
const active = activeRuns.get(activeRunKey(this.boundary.realRoot, id));
if (!active || active.workspaceRoot !== this.boundary.root) {
return await this.status(id);
}
active.controller.abort();
return await active.promise;
}
private async execute(
state: AgentRunState,
signal: AbortSignal,
): Promise<AgentRunState> {
state.status = "running";
state.startedAt = new Date().toISOString();
await this.store.event(state, {
type: "run_started",
summary: `Run started with role '${state.role}'.`,
details: {
modelId: state.modelId,
mode: state.mode,
commitEdits: state.commitEdits,
allowCommands: state.allowCommands,
allowWeb: state.allowWeb,
taskBoardId: state.taskBoardId,
researchProjectId: state.researchProjectId,
},
});
await this.journal.record({
category: "agent",
action: "start",
summary: `Agent run ${state.id} started: ${state.objective.slice(0, 160)}`,
runId: state.id,
});
try {
const model = await this.getModel(state.modelId);
try {
const info = await model.getModelInfo();
if (info.trainedForToolUse === false) {
throw new AgenticError(
"AGENT_DISABLED",
`Model '${state.modelId ?? "default"}' is not marked as tool-capable.`,
);
}
} catch (error) {
if (error instanceof AgenticError) throw error;
// Some SDK/model combinations do not expose model metadata; continue.
}
const completionFlag = { done: false };
const tools = this.createAgentTools(state, completionFlag);
for (let pass = 1; pass <= state.maxPasses; pass++) {
if (signal.aborted) {
const abortError = new Error("Agent run canceled");
abortError.name = "AbortError";
throw abortError;
}
if (completionFlag.done || state.final) break;
state.pass = pass;
await this.store.event(state, {
type: "pass_started",
summary: `Pass ${pass}/${state.maxPasses} started.`,
});
const chat = Chat.from([
{ role: "system", content: this.systemPrompt(state) },
{ role: "user", content: await this.passPrompt(state) },
]);
const messages: ChatMessage[] = [];
const callsBefore = state.toolCalls;
await model.act(chat, tools, {
signal,
maxPredictionRounds: state.roundsPerPass,
contextOverflowPolicy: "stopAtLimit",
onMessage: (message: ChatMessage) => {
messages.push(message);
},
handleInvalidToolRequest: (error: Error, request: unknown) =>
invalidToolRequestReply(error, request),
});
const assistantText = messages
.filter((message) => message.getRole() === "assistant")
.map((message) => message.getText())
.filter(Boolean)
.join("\n\n");
for (const message of messages) {
await this.store.appendTranscript(state, {
pass,
role: message.getRole(),
text: compactText(message.getText(), 4000),
});
}
await this.store.event(state, {
type: "pass_completed",
summary: assistantText
? `Pass ${pass} response: ${compactText(assistantText, 900)}`
: `Pass ${pass} completed without assistant text.`,
details: { toolCalls: state.toolCalls - callsBefore },
});
if (
toolBudgetExhaustedWithoutCompletion(
state.toolCalls,
state.maxToolCalls,
completionFlag.done || Boolean(state.final),
)
) {
throw new AgenticError(
"AGENT_LIMIT",
`Agent reached the tool-call limit (${state.maxToolCalls}).`,
);
}
if (state.toolCalls === callsBefore && !completionFlag.done) {
state.notes = unique([
...state.notes,
"The previous pass made no tool calls; reassess blockers or finish with explicit remaining work.",
]).slice(-20);
await this.store.save(state);
}
}
if (!state.final) {
throw new AgenticError(
"AGENT_LIMIT",
`Agent did not call finish_run within ${state.maxPasses} passes.`,
);
}
try {
await this.finalizeLinkedArtifacts(state);
} catch (error) {
state.notes = unique([
...state.notes,
`Linked artifact finalization warning: ${error instanceof Error ? error.message : String(error)}`,
]).slice(-30);
}
state.status = "completed";
state.finishedAt = new Date().toISOString();
await this.store.save(state);
await this.journal.record({
category: "agent",
action: "completed",
summary: `Agent run ${state.id} completed: ${state.final.summary}`,
runId: state.id,
details: {
filesChanged: state.filesChanged,
transactions: state.transactions,
commands: state.commands.map((command) => command.id),
},
});
return state;
} catch (error) {
const canceled = signal.aborted || isAbortError(error);
state.status = canceled ? "canceled" : "failed";
state.finishedAt = new Date().toISOString();
state.error = error instanceof Error ? error.message : String(error);
if (state.researchProjectId) {
await this.research
.complete(state.researchProjectId, {
summary: `Agent run ${state.id} ${state.status}: ${state.error}`,
error: state.error,
})
.catch(() => undefined);
}
await this.store.event(state, {
type: canceled ? "canceled" : "error",
summary: state.error,
});
await this.journal.record({
category: "agent",
action: state.status,
summary: `Agent run ${state.id} ${state.status}: ${state.error}`,
runId: state.id,
});
return state;
}
}
private createAgentTools(
state: AgentRunState,
completionFlag: { done: boolean },
): Tool[] {
const beforeTool = async (name: string): Promise<void> => {
if (state.toolCalls >= state.maxToolCalls) {
throw new AgenticError("AGENT_LIMIT", "Agent tool-call budget exhausted.");
}
state.toolCalls++;
await this.store.event(state, {
type: "tool",
summary: `Tool ${name} invoked (${state.toolCalls}/${state.maxToolCalls}).`,
});
};
return buildAgentTools({
state,
completionFlag,
beforeTool,
boundary: this.boundary,
store: this.store,
transactions: this.transactions,
processes: this.processes,
tasks: this.tasks,
web: this.web,
research: this.research,
options: this.options,
ensureResearchProject: async (target) => await this.ensureResearchProject(target),
});
}
private async ensureResearchProject(state: AgentRunState): Promise<ResearchProject> {
if (state.researchProjectId) return await this.research.load(state.researchProjectId);
const project = await this.research.create({
title: `Evidence for ${state.objective.slice(0, 150)}`,
objective: state.objective,
});
state.researchProjectId = project.id;
await this.research.attachAgent(project.id, state.id);
await this.store.event(state, {
type: "note",
summary: `Created linked research project ${project.id}.`,
details: { researchProjectId: project.id },
});
return project;
}
private renderResearchReport(
state: AgentRunState,
project: ResearchProject,
): string {
const final = state.final;
const sourceLines = project.sources.length
? project.sources.map(
(source) =>
`- [${source.id}] ${source.title || source.url}\n - URL: ${source.url}\n - Fetched: ${source.fetchedAt}\n - SHA-256: ${source.sha256}\n - Stored: \`${source.contentPath}\``,
)
: ["- No sources were fetched."];
const noteLines = project.notes.length
? project.notes.map(
(note) =>
`- **${note.kind}:** ${note.text}${
note.sourceIds.length ? ` — sources: ${note.sourceIds.join(", ")}` : ""
}`,
)
: ["- No research notes were recorded."];
return [
`# ${project.title}`,
"",
`**Research project:** \`${project.id}\` `,
`**Agent run:** \`${state.id}\` `,
`**Completed:** ${new Date().toISOString()}`,
"",
"## Objective",
"",
project.objective,
"",
"## Summary",
"",
final?.summary ?? "The agent did not provide a final summary.",
"",
"## Evidence",
"",
...(final?.evidence.length
? final.evidence.map((item) => `- ${item}`)
: ["- No final evidence entries were supplied."]),
"",
"## Research notes",
"",
...noteLines,
"",
"## Source ledger",
"",
...sourceLines,
"",
"## Remaining work and uncertainty",
"",
...(final?.remaining.length
? final.remaining.map((item) => `- ${item}`)
: ["- None reported."]),
"",
`**Confidence:** ${final?.confidence ?? 0}`,
"",
].join("\n");
}
private async finalizeLinkedArtifacts(state: AgentRunState): Promise<void> {
if (!state.final) return;
if (state.researchProjectId) {
const project = await this.research.load(state.researchProjectId);
const report = this.renderResearchReport(state, project);
const completed = await this.research.complete(project.id, {
summary: state.final.summary,
report,
});
state.notes = unique([
...state.notes,
`Research report stored at ${completed.reportPath ?? this.research.stateRelativePath(project.id)}.`,
]).slice(-30);
}
if (state.taskBoardId) {
await this.tasks.checkpoint(state.taskBoardId, {
summary: `Agent run ${state.id}: ${state.final.summary}`,
blockers: state.final.remaining,
next: state.final.remaining,
});
}
}
private async getModel(modelId?: string): Promise<LLM> {
return modelId
? await this.client.llm.model(modelId)
: await this.client.llm.model();
}
private systemPrompt(state: AgentRunState): string {
// record_research_note is registered for allowWeb || researchProjectId
// runs; every research run gets a project, but naming the tool that is
// actually in the list keeps the two from drifting apart.
const noteTool =
state.allowWeb || state.researchProjectId ? "record_research_note" : "record_note";
const modeRules =
state.mode === "research"
? state.allowWeb
? [
"This is a deep-research run. Build a query plan, search broadly, fetch the strongest sources, and triangulate material claims.",
"Prefer primary sources, official documentation, standards, papers, and direct records. Use secondary sources to discover or contextualize them.",
"Every important factual finding must identify one or more source ids returned by fetch_source. Distinguish sourced fact, inference, disagreement, and unresolved uncertainty.",
`Do not edit workspace files in research mode. Persist findings with ${noteTool} and finish with a synthesis that names source ids and URLs in evidence.`,
]
: [
// allow_web is model-settable and the config can withdraw web
// research entirely, so this run has no web_search, no
// fetch_source and no source ids at all. Telling it to "fetch the
// strongest sources" was an instruction it could not carry out.
"This is a research run with no web access. The workspace is your only evidence: read and search it broadly, then triangulate material claims across the files that actually exist.",
"Prefer primary evidence — the code, its tests, its configuration and its recorded history — over inference, and never present inference as a finding.",
"Every important factual finding must name the workspace path it came from. Distinguish sourced fact, inference, disagreement, and unresolved uncertainty.",
`Do not edit workspace files in research mode. Persist findings with ${noteTool} and finish with a synthesis that names those paths in evidence.`,
]
: state.mode === "coding"
? [
"This is a software-engineering run. Inspect architecture and existing conventions before editing, make minimal coherent transactions, and verify with focused tests or builds.",
"Create new files with edit_files create; parent directories are created automatically, so a whole scaffold fits in one transaction.",
...(state.allowWeb
? [
"Web research may be used for current official documentation or technical evidence, but repository evidence takes priority for local behavior.",
]
: []),
]
: [
"This is a general autonomous run. Choose the smallest reliable combination of workspace inspection, web evidence, task updates, commands, and transactions.",
];
return [
`You are a ${state.role} operating inside an LM Studio agentic workspace.`,
"Work by evidence, not narration. Inspect before editing. Use tools for every filesystem or command fact.",
...modeRules,
...(state.mode === "research"
? []
: [
// run_command is registered only when state.allowCommands, so
// naming it unconditionally told a Manual-mode or commands-disabled
// sub-agent to call a tool that is not in its list.
state.allowCommands
? "Loop: read_file or search_workspace to find the exact text, edit_files to change it, run_command to run the tests, record_note for what the next pass must know."
: "Loop: read_file or search_workspace to find the exact text, edit_files to change it, record_note for what the next pass must know.",
"edit_files replace takes search and replacement (exact text); create and rewrite take content. read_file output is line-numbered as `12 | code`: strip that prefix before the text goes back into an edit.",
"Edits are transactional. Never claim a file changed unless edit_files reports an applied transaction.",
"Exact-replace conflicts are useful: reread the file and revise the operation instead of overwriting blindly.",
]),
"Keep a durable plan with set_plan. Record decisions or blockers with record_note.",
...(state.taskBoardId
? [
`This run is linked to To-Do board ${state.taskBoardId}. Use task_board to keep task status, evidence, blockers, and checkpoints current.`,
]
: []),
...(state.allowWeb
? [
"Web access is available through web_search and fetch_source. Search-result snippets are discovery hints, not sufficient evidence; fetch the underlying page before relying on it.",
]
: []),
"Run focused tests or checks after changes when command execution is available.",
"This run is reconstructed between bounded passes from structured state, so persist anything future passes need.",
"Call finish_run exactly once when the objective is complete or a blocker is demonstrated. Include concrete evidence and honest remaining work.",
`Mode: ${state.mode}. Edits: ${
state.commitEdits
? "auto-commit"
: "planned only — the user approves commits after the run; cite transaction ids in evidence"
}; allowDestructiveEdits ("Allow destructive commits"): ${
this.options.destructiveEditsEnabled ? "on" : "off"
}. Command execution: ${state.allowCommands ? "enabled" : "disabled"}. Web research: ${
state.allowWeb ? "enabled" : "disabled"
}.`,
].join("\n");
}
private async passPrompt(state: AgentRunState): Promise<string> {
const plan = state.plan.length
? state.plan.map((item) => `- [${item.status}] ${item.text}`).join("\n")
: "- No plan stored yet.";
const events = state.recentEvents.length
? state.recentEvents
.slice(-15)
.map((event) => `- ${event.timestamp}: ${event.summary}`)
.join("\n")
: "- No prior events.";
let taskSection = "- No linked task board.";
if (state.taskBoardId) {
try {
const board = await this.tasks.load(state.taskBoardId);
const next = this.tasks.nextActionable(board).slice(0, 15);
taskSection = [
`- Board: ${board.id} — ${board.title} (${board.status})`,
...next.map(
(item) =>
`- ${item.id} [${item.status}/${item.priority}] ${item.text}${
item.dependsOn.length ? `; depends on ${item.dependsOn.join(", ")}` : ""
}`,
),
...(board.checkpoints.length
? [`- Latest checkpoint: ${board.checkpoints[board.checkpoints.length - 1].summary}`]
: []),
].join("\n");
} catch (error) {
taskSection = `- Linked board unavailable: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
let researchSection = state.researchProjectId
? `- Research project: ${state.researchProjectId}`
: "- No linked research project yet.";
if (state.researchProjectId) {
try {
const project = await this.research.load(state.researchProjectId);
researchSection = [
`- Project: ${project.id} — ${project.title} (${project.status})`,
`- Queries recorded: ${project.queries.length}; sources fetched: ${project.sources.length}; notes: ${project.notes.length}`,
...project.sources.slice(-20).map(
(source) =>
`- [${source.id}] ${source.title || source.url} — ${source.url} — stored ${source.contentPath}`,
),
].join("\n");
} catch (error) {
researchSection = `- Linked research project unavailable: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
return [
`# Objective\n${state.objective}`,
state.context ? `# Supplied context\n${compactText(state.context, 12_000)}` : "",
`# Run mode\n${state.mode}`,
`# Pass\n${state.pass}/${state.maxPasses}`,
`# Budget\n${state.toolCalls}/${state.maxToolCalls} tool calls used`,
`# Durable plan\n${plan}`,
`# Linked To-Do board\n${taskSection}`,
`# Research/source ledger\n${researchSection}`,
`# Files read\n${bulletTail(
state.filesRead.map((file) => `${file.path} @ ${file.sha256.slice(0, 12)}`),
30,
)}`,
`# Files changed\n${bulletTail(state.filesChanged, 30)}`,
`# Transactions\n${bulletTail(state.transactions, 20)}`,
`# Command evidence\n${
state.commands.length
? state.commands.slice(-10).map((command) => `- ${command.summary}`).join("\n")
: "- None"
}`,
`# Durable notes\n${
state.notes.length ? state.notes.slice(-15).map((note) => `- ${note}`).join("\n") : "- None"
}`,
`# Recent events\n${events}`,
"Continue from this state. Prefer a small number of decisive tool calls. Finish explicitly when done.",
]
.filter(Boolean)
.join("\n\n");
}
}
import { createHash } from "node:crypto";
import {
Chat,
type ChatMessage,
type LMStudioClient,
type LLM,
type Tool,
} from "@lmstudio/sdk";
import { AgenticError, invalidToolRequestReply, isAbortError } from "../core/errors";
import { sha256Text } from "../core/hash";
import { createId } from "../core/id";
import type { Journal } from "../core/journal";
import { boundedPreview } from "../core/result";
import type { ProcessService } from "../execution/processRunner";
import type { ResearchProject, ResearchStore } from "../research/store";
import type { SearchProvider, WebResearchService } from "../research/web";
import type { TaskBoardStore } from "../tasks/taskStore";
import type { WorkspaceBoundary } from "../workspace/boundary";
import type { TransactionManager } from "../workspace/transactions";
import {
AgentRunStore,
type AgentRunMode,
type AgentRunState,
} from "./runStore";
import { toolBudgetExhaustedWithoutCompletion } from "./budget";
import { buildAgentTools } from "./tools";
import { unique } from "./tools/context";
export interface AgentRunSpec {
objective: string;
context?: string;
role?: string;
modelId?: string;
mode?: AgentRunMode;
commitEdits?: boolean;
allowCommands?: boolean;
allowWeb?: boolean;
taskBoardId?: string;
researchProjectId?: string;
maxPasses?: number;
roundsPerPass?: number;
maxToolCalls?: number;
idempotencyKey?: string;
}
export interface AgentOrchestratorOptions {
defaultModelId?: string;
defaultCommitEdits: boolean;
destructiveEditsEnabled: boolean;
commandsEnabled: boolean;
webEnabled: boolean;
defaultSearchProvider: SearchProvider;
maxPasses: number;
roundsPerPass: number;
maxToolCalls: number;
maxReadBytes: number;
maxResultChars: number;
}
interface ActiveRun {
workspaceRoot: string;
specHash: string;
controller: AbortController;
promise: Promise<AgentRunState>;
}
const activeRuns = new Map<string, ActiveRun>();
const runStartLocks = new Map<string, Promise<void>>();
function activeRunKey(workspaceRoot: string, id: string): string {
return `${workspaceRoot}\0${id}`;
}
async function withRunStartLock<T>(id: string, action: () => Promise<T>): Promise<T> {
const previous = runStartLocks.get(id) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const queued = previous.then(() => current);
runStartLocks.set(id, queued);
await previous;
try {
return await action();
} finally {
release();
if (runStartLocks.get(id) === queued) runStartLocks.delete(id);
}
}
function runIdFor(spec: AgentRunSpec): string {
if (spec.idempotencyKey) {
return `run_${createHash("sha256")
.update(`agentic-workspace/v1:${spec.idempotencyKey}`)
.digest("hex")
.slice(0, 24)}`;
}
return createId("run");
}
/** Shape limits on a run request. Tools call this before gating so an invalid request is never staged. */
export function validateSpec(spec: AgentRunSpec): void {
if (!spec.objective?.trim()) {
throw new AgenticError("INVALID_INPUT", "Agent objective may not be empty.");
}
if (spec.objective.length > 20_000) {
throw new AgenticError("INVALID_INPUT", "Agent objective is limited to 20,000 characters.");
}
if ((spec.context?.length ?? 0) > 100_000) {
throw new AgenticError("INVALID_INPUT", "Agent context is limited to 100,000 characters.");
}
}
export function hashRunSpec(spec: AgentRunSpec): string {
return sha256Text(
JSON.stringify({
objective: spec.objective.trim(),
context: spec.context?.trim() || undefined,
role: spec.role?.trim() || undefined,
modelId: spec.modelId?.trim() || undefined,
mode: spec.mode,
commitEdits: spec.commitEdits,
allowCommands: spec.allowCommands,
allowWeb: spec.allowWeb,
taskBoardId: spec.taskBoardId,
researchProjectId: spec.researchProjectId,
maxPasses: spec.maxPasses,
roundsPerPass: spec.roundsPerPass,
maxToolCalls: spec.maxToolCalls,
idempotencyKey: spec.idempotencyKey,
}),
);
}
function compactText(value: string, limit = 1200): string {
return boundedPreview(value.trim(), limit).preview;
}
/**
* Renders the most recent `limit` values as prompt bullets, saying so when the
* list is longer. Every accumulating section of the pass prompt is a tail, not
* a full history: the prompt is rebuilt from durable state on every pass, so an
* unsliced section makes each pass more expensive than the last.
*/
function bulletTail(values: string[], limit: number): string {
if (values.length === 0) return "- None";
const shown = values.slice(-limit);
const header =
shown.length < values.length
? `- (most recent ${shown.length} of ${values.length})\n`
: "";
return `${header}${shown.map((value) => `- ${value}`).join("\n")}`;
}
export class AgentOrchestrator {
public constructor(
private readonly client: LMStudioClient,
private readonly boundary: WorkspaceBoundary,
private readonly store: AgentRunStore,
private readonly transactions: TransactionManager,
private readonly processes: ProcessService,
private readonly tasks: TaskBoardStore,
private readonly web: WebResearchService,
private readonly research: ResearchStore,
private readonly journal: Journal,
private readonly options: AgentOrchestratorOptions,
) {}
public async start(spec: AgentRunSpec): Promise<{
state: AgentRunState;
promise: Promise<AgentRunState>;
}> {
validateSpec(spec);
const id = runIdFor(spec);
if (!spec.idempotencyKey) return await this.startUnlocked(spec, id);
const key = activeRunKey(this.boundary.realRoot, id);
return await withRunStartLock(key, async () => await this.startUnlocked(spec, id));
}
private async startUnlocked(
spec: AgentRunSpec,
id: string,
): Promise<{ state: AgentRunState; promise: Promise<AgentRunState> }> {
await this.store.initialize();
const specHash = hashRunSpec(spec);
const runKey = activeRunKey(this.boundary.realRoot, id);
const active = activeRuns.get(runKey);
if (active) {
if (active.workspaceRoot !== this.boundary.root) {
throw new AgenticError("EDIT_CONFLICT", "Agent run id belongs to another workspace.");
}
if (active.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The agent idempotency key is already active with a different run specification.",
);
}
return { state: await this.store.load(id), promise: active.promise };
}
try {
const existing = await this.store.load(id);
if (existing.specHash !== specHash) {
throw new AgenticError(
"EDIT_CONFLICT",
"The agent idempotency key was already used with a different run specification.",
);
}
if (existing.status === "running" || existing.status === "queued") {
existing.status = "orphaned";
existing.finishedAt = new Date().toISOString();
existing.error = "Plugin restarted while the run was active; execution ownership was lost.";
await this.store.save(existing);
}
return { state: existing, promise: Promise.resolve(existing) };
} catch (error) {
if (!(error instanceof AgenticError) || error.code !== "NOT_FOUND") throw error;
}
const mode: AgentRunMode = spec.mode ?? "coding";
const taskBoardId = spec.taskBoardId?.trim() || undefined;
if (taskBoardId) await this.tasks.load(taskBoardId);
let researchProjectId = spec.researchProjectId?.trim() || undefined;
let linkedResearch = researchProjectId
? await this.research.load(researchProjectId)
: undefined;
if (mode === "research" && !linkedResearch) {
linkedResearch = await this.research.create({
title: spec.objective.trim().slice(0, 180),
objective: spec.objective.trim(),
});
researchProjectId = linkedResearch.id;
}
const now = new Date().toISOString();
const state: AgentRunState = {
version: 1,
id,
specHash,
...(spec.idempotencyKey ? { idempotencyKey: spec.idempotencyKey } : {}),
status: "queued",
objective: spec.objective.trim(),
...(spec.context?.trim() ? { context: spec.context.trim() } : {}),
role:
spec.role?.trim() ||
(mode === "research"
? "deep research agent"
: mode === "general"
? "autonomous execution agent"
: "software engineering agent"),
...(spec.modelId?.trim() || this.options.defaultModelId
? { modelId: spec.modelId?.trim() || this.options.defaultModelId }
: {}),
mode,
commitEdits: spec.commitEdits ?? this.options.defaultCommitEdits,
allowCommands:
(spec.allowCommands ?? mode !== "research") && this.options.commandsEnabled,
allowWeb: (spec.allowWeb ?? true) && this.options.webEnabled,
...(taskBoardId ? { taskBoardId } : {}),
...(researchProjectId ? { researchProjectId } : {}),
createdAt: now,
updatedAt: now,
pass: 0,
maxPasses: Math.max(1, Math.min(spec.maxPasses ?? this.options.maxPasses, 20)),
roundsPerPass: Math.max(
1,
Math.min(spec.roundsPerPass ?? this.options.roundsPerPass, 12),
),
toolCalls: 0,
maxToolCalls: Math.max(
1,
Math.min(spec.maxToolCalls ?? this.options.maxToolCalls, 200),
),
plan: [],
filesRead: [],
filesChanged: [],
transactions: [],
commands: [],
sources: (linkedResearch?.sources ?? []).map((source) => ({
id: source.id,
url: source.url,
...(source.title ? { title: source.title } : {}),
contentPath: source.contentPath,
sha256: source.sha256,
})),
notes: [],
recentEvents: [],
};
await this.store.create(state);
if (researchProjectId) await this.research.attachAgent(researchProjectId, id);
const controller = new AbortController();
const promise = this.execute(state, controller.signal).finally(() => {
activeRuns.delete(runKey);
});
activeRuns.set(runKey, {
workspaceRoot: this.boundary.root,
specHash,
controller,
promise,
});
return { state, promise };
}
public async run(spec: AgentRunSpec): Promise<AgentRunState> {
return await (await this.start(spec)).promise;
}
public async status(id: string): Promise<AgentRunState> {
const state = await this.store.load(id);
const active = activeRuns.get(activeRunKey(this.boundary.realRoot, id));
if (
(state.status === "running" || state.status === "queued") &&
(!active || active.workspaceRoot !== this.boundary.root)
) {
state.status = "orphaned";
state.finishedAt = new Date().toISOString();
state.error = "Plugin restarted while the run was active; execution ownership was lost.";
await this.store.save(state);
}
return state;
}
public async list(limit = 20): Promise<AgentRunState[]> {
const states = await this.store.list(limit);
return await Promise.all(
states.map(async (state) =>
state.status === "running" || state.status === "queued"
? await this.status(state.id)
: state,
),
);
}
public async history(id: string, limit?: number) {
return await this.store.history(id, limit);
}
public async cancel(id: string): Promise<AgentRunState> {
const active = activeRuns.get(activeRunKey(this.boundary.realRoot, id));
if (!active || active.workspaceRoot !== this.boundary.root) {
return await this.status(id);
}
active.controller.abort();
return await active.promise;
}
private async execute(
state: AgentRunState,
signal: AbortSignal,
): Promise<AgentRunState> {
state.status = "running";
state.startedAt = new Date().toISOString();
await this.store.event(state, {
type: "run_started",
summary: `Run started with role '${state.role}'.`,
details: {
modelId: state.modelId,
mode: state.mode,
commitEdits: state.commitEdits,
allowCommands: state.allowCommands,
allowWeb: state.allowWeb,
taskBoardId: state.taskBoardId,
researchProjectId: state.researchProjectId,
},
});
await this.journal.record({
category: "agent",
action: "start",
summary: `Agent run ${state.id} started: ${state.objective.slice(0, 160)}`,
runId: state.id,
});
try {
const model = await this.getModel(state.modelId);
try {
const info = await model.getModelInfo();
if (info.trainedForToolUse === false) {
throw new AgenticError(
"AGENT_DISABLED",
`Model '${state.modelId ?? "default"}' is not marked as tool-capable.`,
);
}
} catch (error) {
if (error instanceof AgenticError) throw error;
// Some SDK/model combinations do not expose model metadata; continue.
}
const completionFlag = { done: false };
const tools = this.createAgentTools(state, completionFlag);
for (let pass = 1; pass <= state.maxPasses; pass++) {
if (signal.aborted) {
const abortError = new Error("Agent run canceled");
abortError.name = "AbortError";
throw abortError;
}
if (completionFlag.done || state.final) break;
state.pass = pass;
await this.store.event(state, {
type: "pass_started",
summary: `Pass ${pass}/${state.maxPasses} started.`,
});
const chat = Chat.from([
{ role: "system", content: this.systemPrompt(state) },
{ role: "user", content: await this.passPrompt(state) },
]);
const messages: ChatMessage[] = [];
const callsBefore = state.toolCalls;
await model.act(chat, tools, {
signal,
maxPredictionRounds: state.roundsPerPass,
contextOverflowPolicy: "stopAtLimit",
onMessage: (message: ChatMessage) => {
messages.push(message);
},
handleInvalidToolRequest: (error: Error, request: unknown) =>
invalidToolRequestReply(error, request),
});
const assistantText = messages
.filter((message) => message.getRole() === "assistant")
.map((message) => message.getText())
.filter(Boolean)
.join("\n\n");
for (const message of messages) {
await this.store.appendTranscript(state, {
pass,
role: message.getRole(),
text: compactText(message.getText(), 4000),
});
}
await this.store.event(state, {
type: "pass_completed",
summary: assistantText
? `Pass ${pass} response: ${compactText(assistantText, 900)}`
: `Pass ${pass} completed without assistant text.`,
details: { toolCalls: state.toolCalls - callsBefore },
});
if (
toolBudgetExhaustedWithoutCompletion(
state.toolCalls,
state.maxToolCalls,
completionFlag.done || Boolean(state.final),
)
) {
throw new AgenticError(
"AGENT_LIMIT",
`Agent reached the tool-call limit (${state.maxToolCalls}).`,
);
}
if (state.toolCalls === callsBefore && !completionFlag.done) {
state.notes = unique([
...state.notes,
"The previous pass made no tool calls; reassess blockers or finish with explicit remaining work.",
]).slice(-20);
await this.store.save(state);
}
}
if (!state.final) {
throw new AgenticError(
"AGENT_LIMIT",
`Agent did not call finish_run within ${state.maxPasses} passes.`,
);
}
try {
await this.finalizeLinkedArtifacts(state);
} catch (error) {
state.notes = unique([
...state.notes,
`Linked artifact finalization warning: ${error instanceof Error ? error.message : String(error)}`,
]).slice(-30);
}
state.status = "completed";
state.finishedAt = new Date().toISOString();
await this.store.save(state);
await this.journal.record({
category: "agent",
action: "completed",
summary: `Agent run ${state.id} completed: ${state.final.summary}`,
runId: state.id,
details: {
filesChanged: state.filesChanged,
transactions: state.transactions,
commands: state.commands.map((command) => command.id),
},
});
return state;
} catch (error) {
const canceled = signal.aborted || isAbortError(error);
state.status = canceled ? "canceled" : "failed";
state.finishedAt = new Date().toISOString();
state.error = error instanceof Error ? error.message : String(error);
if (state.researchProjectId) {
await this.research
.complete(state.researchProjectId, {
summary: `Agent run ${state.id} ${state.status}: ${state.error}`,
error: state.error,
})
.catch(() => undefined);
}
await this.store.event(state, {
type: canceled ? "canceled" : "error",
summary: state.error,
});
await this.journal.record({
category: "agent",
action: state.status,
summary: `Agent run ${state.id} ${state.status}: ${state.error}`,
runId: state.id,
});
return state;
}
}
private createAgentTools(
state: AgentRunState,
completionFlag: { done: boolean },
): Tool[] {
const beforeTool = async (name: string): Promise<void> => {
if (state.toolCalls >= state.maxToolCalls) {
throw new AgenticError("AGENT_LIMIT", "Agent tool-call budget exhausted.");
}
state.toolCalls++;
await this.store.event(state, {
type: "tool",
summary: `Tool ${name} invoked (${state.toolCalls}/${state.maxToolCalls}).`,
});
};
return buildAgentTools({
state,
completionFlag,
beforeTool,
boundary: this.boundary,
store: this.store,
transactions: this.transactions,
processes: this.processes,
tasks: this.tasks,
web: this.web,
research: this.research,
options: this.options,
ensureResearchProject: async (target) => await this.ensureResearchProject(target),
});
}
private async ensureResearchProject(state: AgentRunState): Promise<ResearchProject> {
if (state.researchProjectId) return await this.research.load(state.researchProjectId);
const project = await this.research.create({
title: `Evidence for ${state.objective.slice(0, 150)}`,
objective: state.objective,
});
state.researchProjectId = project.id;
await this.research.attachAgent(project.id, state.id);
await this.store.event(state, {
type: "note",
summary: `Created linked research project ${project.id}.`,
details: { researchProjectId: project.id },
});
return project;
}
private renderResearchReport(
state: AgentRunState,
project: ResearchProject,
): string {
const final = state.final;
const sourceLines = project.sources.length
? project.sources.map(
(source) =>
`- [${source.id}] ${source.title || source.url}\n - URL: ${source.url}\n - Fetched: ${source.fetchedAt}\n - SHA-256: ${source.sha256}\n - Stored: \`${source.contentPath}\``,
)
: ["- No sources were fetched."];
const noteLines = project.notes.length
? project.notes.map(
(note) =>
`- **${note.kind}:** ${note.text}${
note.sourceIds.length ? ` — sources: ${note.sourceIds.join(", ")}` : ""
}`,
)
: ["- No research notes were recorded."];
return [
`# ${project.title}`,
"",
`**Research project:** \`${project.id}\` `,
`**Agent run:** \`${state.id}\` `,
`**Completed:** ${new Date().toISOString()}`,
"",
"## Objective",
"",
project.objective,
"",
"## Summary",
"",
final?.summary ?? "The agent did not provide a final summary.",
"",
"## Evidence",
"",
...(final?.evidence.length
? final.evidence.map((item) => `- ${item}`)
: ["- No final evidence entries were supplied."]),
"",
"## Research notes",
"",
...noteLines,
"",
"## Source ledger",
"",
...sourceLines,
"",
"## Remaining work and uncertainty",
"",
...(final?.remaining.length
? final.remaining.map((item) => `- ${item}`)
: ["- None reported."]),
"",
`**Confidence:** ${final?.confidence ?? 0}`,
"",
].join("\n");
}
private async finalizeLinkedArtifacts(state: AgentRunState): Promise<void> {
if (!state.final) return;
if (state.researchProjectId) {
const project = await this.research.load(state.researchProjectId);
const report = this.renderResearchReport(state, project);
const completed = await this.research.complete(project.id, {
summary: state.final.summary,
report,
});
state.notes = unique([
...state.notes,
`Research report stored at ${completed.reportPath ?? this.research.stateRelativePath(project.id)}.`,
]).slice(-30);
}
if (state.taskBoardId) {
await this.tasks.checkpoint(state.taskBoardId, {
summary: `Agent run ${state.id}: ${state.final.summary}`,
blockers: state.final.remaining,
next: state.final.remaining,
});
}
}
private async getModel(modelId?: string): Promise<LLM> {
return modelId
? await this.client.llm.model(modelId)
: await this.client.llm.model();
}
private systemPrompt(state: AgentRunState): string {
// record_research_note is registered for allowWeb || researchProjectId
// runs; every research run gets a project, but naming the tool that is
// actually in the list keeps the two from drifting apart.
const noteTool =
state.allowWeb || state.researchProjectId ? "record_research_note" : "record_note";
const modeRules =
state.mode === "research"
? state.allowWeb
? [
"This is a deep-research run. Build a query plan, search broadly, fetch the strongest sources, and triangulate material claims.",
"Prefer primary sources, official documentation, standards, papers, and direct records. Use secondary sources to discover or contextualize them.",
"Every important factual finding must identify one or more source ids returned by fetch_source. Distinguish sourced fact, inference, disagreement, and unresolved uncertainty.",
`Do not edit workspace files in research mode. Persist findings with ${noteTool} and finish with a synthesis that names source ids and URLs in evidence.`,
]
: [
// allow_web is model-settable and the config can withdraw web
// research entirely, so this run has no web_search, no
// fetch_source and no source ids at all. Telling it to "fetch the
// strongest sources" was an instruction it could not carry out.
"This is a research run with no web access. The workspace is your only evidence: read and search it broadly, then triangulate material claims across the files that actually exist.",
"Prefer primary evidence — the code, its tests, its configuration and its recorded history — over inference, and never present inference as a finding.",
"Every important factual finding must name the workspace path it came from. Distinguish sourced fact, inference, disagreement, and unresolved uncertainty.",
`Do not edit workspace files in research mode. Persist findings with ${noteTool} and finish with a synthesis that names those paths in evidence.`,
]
: state.mode === "coding"
? [
"This is a software-engineering run. Inspect architecture and existing conventions before editing, make minimal coherent transactions, and verify with focused tests or builds.",
"Create new files with edit_files create; parent directories are created automatically, so a whole scaffold fits in one transaction.",
...(state.allowWeb
? [
"Web research may be used for current official documentation or technical evidence, but repository evidence takes priority for local behavior.",
]
: []),
]
: [
"This is a general autonomous run. Choose the smallest reliable combination of workspace inspection, web evidence, task updates, commands, and transactions.",
];
return [
`You are a ${state.role} operating inside an LM Studio agentic workspace.`,
"Work by evidence, not narration. Inspect before editing. Use tools for every filesystem or command fact.",
...modeRules,
...(state.mode === "research"
? []
: [
// run_command is registered only when state.allowCommands, so
// naming it unconditionally told a Manual-mode or commands-disabled
// sub-agent to call a tool that is not in its list.
state.allowCommands
? "Loop: read_file or search_workspace to find the exact text, edit_files to change it, run_command to run the tests, record_note for what the next pass must know."
: "Loop: read_file or search_workspace to find the exact text, edit_files to change it, record_note for what the next pass must know.",
"edit_files replace takes search and replacement (exact text); create and rewrite take content. read_file output is line-numbered as `12 | code`: strip that prefix before the text goes back into an edit.",
"Edits are transactional. Never claim a file changed unless edit_files reports an applied transaction.",
"Exact-replace conflicts are useful: reread the file and revise the operation instead of overwriting blindly.",
]),
"Keep a durable plan with set_plan. Record decisions or blockers with record_note.",
...(state.taskBoardId
? [
`This run is linked to To-Do board ${state.taskBoardId}. Use task_board to keep task status, evidence, blockers, and checkpoints current.`,
]
: []),
...(state.allowWeb
? [
"Web access is available through web_search and fetch_source. Search-result snippets are discovery hints, not sufficient evidence; fetch the underlying page before relying on it.",
]
: []),
"Run focused tests or checks after changes when command execution is available.",
"This run is reconstructed between bounded passes from structured state, so persist anything future passes need.",
"Call finish_run exactly once when the objective is complete or a blocker is demonstrated. Include concrete evidence and honest remaining work.",
`Mode: ${state.mode}. Edits: ${
state.commitEdits
? "auto-commit"
: "planned only — the user approves commits after the run; cite transaction ids in evidence"
}; allowDestructiveEdits ("Allow destructive commits"): ${
this.options.destructiveEditsEnabled ? "on" : "off"
}. Command execution: ${state.allowCommands ? "enabled" : "disabled"}. Web research: ${
state.allowWeb ? "enabled" : "disabled"
}.`,
].join("\n");
}
private async passPrompt(state: AgentRunState): Promise<string> {
const plan = state.plan.length
? state.plan.map((item) => `- [${item.status}] ${item.text}`).join("\n")
: "- No plan stored yet.";
const events = state.recentEvents.length
? state.recentEvents
.slice(-15)
.map((event) => `- ${event.timestamp}: ${event.summary}`)
.join("\n")
: "- No prior events.";
let taskSection = "- No linked task board.";
if (state.taskBoardId) {
try {
const board = await this.tasks.load(state.taskBoardId);
const next = this.tasks.nextActionable(board).slice(0, 15);
taskSection = [
`- Board: ${board.id} — ${board.title} (${board.status})`,
...next.map(
(item) =>
`- ${item.id} [${item.status}/${item.priority}] ${item.text}${
item.dependsOn.length ? `; depends on ${item.dependsOn.join(", ")}` : ""
}`,
),
...(board.checkpoints.length
? [`- Latest checkpoint: ${board.checkpoints[board.checkpoints.length - 1].summary}`]
: []),
].join("\n");
} catch (error) {
taskSection = `- Linked board unavailable: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
let researchSection = state.researchProjectId
? `- Research project: ${state.researchProjectId}`
: "- No linked research project yet.";
if (state.researchProjectId) {
try {
const project = await this.research.load(state.researchProjectId);
researchSection = [
`- Project: ${project.id} — ${project.title} (${project.status})`,
`- Queries recorded: ${project.queries.length}; sources fetched: ${project.sources.length}; notes: ${project.notes.length}`,
...project.sources.slice(-20).map(
(source) =>
`- [${source.id}] ${source.title || source.url} — ${source.url} — stored ${source.contentPath}`,
),
].join("\n");
} catch (error) {
researchSection = `- Linked research project unavailable: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
return [
`# Objective\n${state.objective}`,
state.context ? `# Supplied context\n${compactText(state.context, 12_000)}` : "",
`# Run mode\n${state.mode}`,
`# Pass\n${state.pass}/${state.maxPasses}`,
`# Budget\n${state.toolCalls}/${state.maxToolCalls} tool calls used`,
`# Durable plan\n${plan}`,
`# Linked To-Do board\n${taskSection}`,
`# Research/source ledger\n${researchSection}`,
`# Files read\n${bulletTail(
state.filesRead.map((file) => `${file.path} @ ${file.sha256.slice(0, 12)}`),
30,
)}`,
`# Files changed\n${bulletTail(state.filesChanged, 30)}`,
`# Transactions\n${bulletTail(state.transactions, 20)}`,
`# Command evidence\n${
state.commands.length
? state.commands.slice(-10).map((command) => `- ${command.summary}`).join("\n")
: "- None"
}`,
`# Durable notes\n${
state.notes.length ? state.notes.slice(-15).map((note) => `- ${note}`).join("\n") : "- None"
}`,
`# Recent events\n${events}`,
"Continue from this state. Prefer a small number of decisive tool calls. Finish explicitly when done.",
]
.filter(Boolean)
.join("\n\n");
}
}