mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +02:00
Make chat work directory per-run instead of global
The work directory was stored in a single shared config file, so setting it in one chat changed it for every chat and new chats inherited a stale value. Store it as run-scoped metadata: captured on the start event at creation and updatable mid-chat via an appended workdir-changed event. The runtime reads the per-run value from AgentState, and the chat input reads/writes it against the run (pending chats pass it into runs:create).
This commit is contained in:
parent
b9c4099c3b
commit
ce1913d4e0
9 changed files with 113 additions and 40 deletions
|
|
@ -36,19 +36,6 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.
|
|||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
|
||||
|
||||
function loadUserWorkDir(): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
||||
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { path?: unknown };
|
||||
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
||||
return value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadAgentNotesContext(): string | null {
|
||||
const sections: string[] = [];
|
||||
|
|
@ -673,6 +660,7 @@ export class AgentState {
|
|||
runProvider: string | null = null;
|
||||
runUseCase: UseCase | null = null;
|
||||
runSubUseCase: string | null = null;
|
||||
workingDirectory: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
subflowStates: Record<string, AgentState> = {};
|
||||
|
|
@ -790,6 +778,10 @@ export class AgentState {
|
|||
this.runProvider = event.provider;
|
||||
this.runUseCase = event.useCase ?? null;
|
||||
this.runSubUseCase = event.subUseCase ?? null;
|
||||
this.workingDirectory = event.workingDirectory ?? null;
|
||||
break;
|
||||
case "workdir-changed":
|
||||
this.workingDirectory = event.workingDirectory ?? null;
|
||||
break;
|
||||
case "spawn-subflow":
|
||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||
|
|
@ -1122,7 +1114,7 @@ export async function* streamAgent({
|
|||
if (agentNotesContext) {
|
||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||
}
|
||||
const userWorkDir = loadUserWorkDir();
|
||||
const userWorkDir = state.workingDirectory;
|
||||
if (userWorkDir) {
|
||||
loopLogger.log('injecting user work directory', userWorkDir);
|
||||
instructionsWithDateTime += `\n\n# User Work Directory
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import path from "path";
|
|||
import fsp from "fs/promises";
|
||||
import fs from "fs";
|
||||
import readline from "readline";
|
||||
import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase } from "@x/shared/dist/runs.js";
|
||||
import { Run, RunEvent, StartEvent, ListRunsResponse, MessageEvent, UseCase, WorkdirChangedEvent } from "@x/shared/dist/runs.js";
|
||||
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
||||
|
||||
/**
|
||||
|
|
@ -37,6 +37,7 @@ export type CreateRunRepoOptions = {
|
|||
provider: string;
|
||||
useCase: z.infer<typeof UseCase>;
|
||||
subUseCase?: string;
|
||||
workingDirectory?: string;
|
||||
};
|
||||
|
||||
function runLogPath(runId: string): string {
|
||||
|
|
@ -206,6 +207,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
provider: options.provider,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}),
|
||||
subflow: [],
|
||||
ts,
|
||||
};
|
||||
|
|
@ -218,6 +220,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
provider: options.provider,
|
||||
useCase: options.useCase,
|
||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||
...(options.workingDirectory ? { workingDirectory: options.workingDirectory } : {}),
|
||||
log: [start],
|
||||
};
|
||||
}
|
||||
|
|
@ -244,6 +247,14 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
};
|
||||
const events: z.infer<typeof RunEvent>[] = [start, ...rawEvents.slice(1) as z.infer<typeof RunEvent>[]];
|
||||
const title = this.extractTitle(events);
|
||||
// The current work directory is the start event's value, overridden by the
|
||||
// most recent workdir-changed event (append-only log — last write wins).
|
||||
let workingDirectory: string | undefined = start.workingDirectory || undefined;
|
||||
for (const event of events) {
|
||||
if (event.type === 'workdir-changed') {
|
||||
workingDirectory = (event as z.infer<typeof WorkdirChangedEvent>).workingDirectory || undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
|
|
@ -253,6 +264,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
provider: start.provider,
|
||||
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||
...(workingDirectory ? { workingDirectory } : {}),
|
||||
log: events,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import z from "zod";
|
||||
import container from "../di/container.js";
|
||||
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload, WorkdirChangedEvent } from "@x/shared/dist/runs.js";
|
||||
import { IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
|
|
@ -34,11 +34,23 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
|||
provider,
|
||||
useCase,
|
||||
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
||||
...(opts.workingDirectory ? { workingDirectory: opts.workingDirectory } : {}),
|
||||
});
|
||||
await bus.publish(run.log[0]);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function setWorkdir(runId: string, workingDirectory: string | null): Promise<void> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const event: z.infer<typeof WorkdirChangedEvent> = {
|
||||
runId,
|
||||
type: "workdir-changed",
|
||||
subflow: [],
|
||||
...(workingDirectory ? { workingDirectory } : {}),
|
||||
};
|
||||
await repo.appendEvents(runId, [event]);
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
|
||||
|
|
|
|||
|
|
@ -234,6 +234,15 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: Run,
|
||||
},
|
||||
'runs:setWorkdir': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
workingDirectory: z.string().nullable(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'runs:list': {
|
||||
req: z.object({
|
||||
cursor: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
"knowledge_sync",
|
||||
]).optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
// Per-run work directory chosen at creation. Optional: a run may have none,
|
||||
// and it can be changed later via WorkdirChangedEvent.
|
||||
workingDirectory: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
||||
|
|
@ -105,6 +108,12 @@ export const RunStoppedEvent = BaseRunEvent.extend({
|
|||
reason: z.enum(["user-requested", "force-stopped"]).optional(),
|
||||
});
|
||||
|
||||
export const WorkdirChangedEvent = BaseRunEvent.extend({
|
||||
type: z.literal("workdir-changed"),
|
||||
// Absent/empty means the work directory was cleared.
|
||||
workingDirectory: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RunEvent = z.union([
|
||||
RunProcessingStartEvent,
|
||||
RunProcessingEndEvent,
|
||||
|
|
@ -121,6 +130,7 @@ export const RunEvent = z.union([
|
|||
ToolPermissionResponseEvent,
|
||||
RunErrorEvent,
|
||||
RunStoppedEvent,
|
||||
WorkdirChangedEvent,
|
||||
]);
|
||||
|
||||
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
||||
|
|
@ -153,6 +163,7 @@ export const Run = z.object({
|
|||
provider: z.string(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
|
||||
|
|
@ -172,4 +183,5 @@ export const CreateRunOptions = z.object({
|
|||
provider: z.string().optional(),
|
||||
useCase: UseCase.optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
workingDirectory: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue