mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
refactor(x): workspace-context loading gets one trait-gated chokepoint
The workspaceContext trait was consulted separately at every assembly site before loading agent notes / work dir — a convention each caller had to re-remember, where a forgotten check either leaks the user's agent-memory into non-copilot prompts or silently omits it for the copilot, and neither fails loudly. The loaders move out of the legacy runtime file into agents/workspace-context.ts, and loadWorkspaceContext() applies the trait gate INSIDE: both engines now call it (the resolver keeps its loader test-seams by injecting them through), so a future assembly site structurally cannot skip the gate. Tests pin the gate: non-workspace agents get nulls even when the loaders would yield values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d0018daad3
commit
efeaae1e19
4 changed files with 169 additions and 82 deletions
|
|
@ -12,6 +12,7 @@ import { TOOL_ADDITIONS_KEY } from "../application/lib/tool-additions.js";
|
||||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
|
||||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||||
import { hasWorkspaceContext, loadAgent } from "./registry.js";
|
import { hasWorkspaceContext, loadAgent } from "./registry.js";
|
||||||
|
import { loadWorkspaceContext } from "./workspace-context.js";
|
||||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||||
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
||||||
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
||||||
|
|
@ -31,14 +32,6 @@ import { captureLlmUsage } from "../analytics/usage.js";
|
||||||
import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js";
|
import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js";
|
||||||
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
|
import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js";
|
||||||
|
|
||||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
|
||||||
|
|
||||||
// Work directory is scoped per run (per chat). Each run gets its own sidecar
|
|
||||||
// config file so setting it in one chat does not leak into others.
|
|
||||||
function workDirConfigFile(runId: string): string {
|
|
||||||
return path.join(WorkDir, 'config', `workdir-${runId}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
|
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
|
||||||
|
|
||||||
function isPathInside(parent: string, child: string): boolean {
|
function isPathInside(parent: string, child: string): boolean {
|
||||||
|
|
@ -164,71 +157,6 @@ export async function getToolPermissionMetadata(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadUserWorkDir(runId: string): string | null {
|
|
||||||
try {
|
|
||||||
const file = workDirConfigFile(runId);
|
|
||||||
if (!fs.existsSync(file)) return null;
|
|
||||||
const raw = fs.readFileSync(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadAgentNotesContext(): string | null {
|
|
||||||
const sections: string[] = [];
|
|
||||||
|
|
||||||
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
|
|
||||||
const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(userFile)) {
|
|
||||||
const content = fs.readFileSync(userFile, 'utf-8').trim();
|
|
||||||
if (content) {
|
|
||||||
sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(prefsFile)) {
|
|
||||||
const content = fs.readFileSync(prefsFile, 'utf-8').trim();
|
|
||||||
if (content) {
|
|
||||||
sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
// List other Agent Notes files for on-demand access
|
|
||||||
const otherFiles: string[] = [];
|
|
||||||
const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']);
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(AGENT_NOTES_DIR)) {
|
|
||||||
function listMdFiles(dir: string, prefix: string) {
|
|
||||||
for (const entry of fs.readdirSync(dir)) {
|
|
||||||
const fullPath = path.join(dir, entry);
|
|
||||||
const stat = fs.statSync(fullPath);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
listMdFiles(fullPath, `${prefix}${entry}/`);
|
|
||||||
} else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) {
|
|
||||||
otherFiles.push(`${prefix}${entry}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listMdFiles(AGENT_NOTES_DIR, '');
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
if (otherFiles.length > 0) {
|
|
||||||
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sections.length === 0) return null;
|
|
||||||
return `# Agent Memory\n\n${sections.join('\n\n')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrentDateTime(now: Date): string {
|
function formatCurrentDateTime(now: Date): string {
|
||||||
return now.toLocaleString('en-US', {
|
return now.toLocaleString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
|
|
@ -1312,11 +1240,11 @@ export async function* streamAgent({
|
||||||
loopLogger.log('running llm turn');
|
loopLogger.log('running llm turn');
|
||||||
// stream agent response and build message
|
// stream agent response and build message
|
||||||
const messageBuilder = new StreamStepMessageBuilder();
|
const messageBuilder = new StreamStepMessageBuilder();
|
||||||
const composeCopilotContext = hasWorkspaceContext(state.agentName);
|
const workspace = loadWorkspaceContext(state.agentName, runId);
|
||||||
const instructionsWithDateTime = composeSystemInstructions({
|
const instructionsWithDateTime = composeSystemInstructions({
|
||||||
instructions: agent.instructions,
|
instructions: agent.instructions,
|
||||||
agentNotesContext: composeCopilotContext ? loadAgentNotesContext() : null,
|
agentNotesContext: workspace.agentNotesContext,
|
||||||
userWorkDir: composeCopilotContext ? loadUserWorkDir(runId) : null,
|
userWorkDir: workspace.userWorkDir,
|
||||||
voiceInput,
|
voiceInput,
|
||||||
voiceOutput,
|
voiceOutput,
|
||||||
searchEnabled,
|
searchEnabled,
|
||||||
|
|
|
||||||
41
apps/x/packages/core/src/agents/workspace-context.test.ts
Normal file
41
apps/x/packages/core/src/agents/workspace-context.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadWorkspaceContext } from "./workspace-context.js";
|
||||||
|
|
||||||
|
const loaders = {
|
||||||
|
loadNotes: () => "# Agent Memory\n\nnotes",
|
||||||
|
loadWorkDir: (key: string) => (key === "chat-1" ? "/Users/me/work" : null),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("loadWorkspaceContext", () => {
|
||||||
|
it("loads notes and work dir for workspaceContext-trait agents", () => {
|
||||||
|
expect(loadWorkspaceContext("copilot", "chat-1", loaders)).toEqual({
|
||||||
|
agentNotesContext: "# Agent Memory\n\nnotes",
|
||||||
|
userWorkDir: "/Users/me/work",
|
||||||
|
});
|
||||||
|
// rowboatx is the copilot alias.
|
||||||
|
expect(loadWorkspaceContext("rowboatx", "chat-1", loaders).userWorkDir).toBe(
|
||||||
|
"/Users/me/work",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nulls for non-workspace agents even when the loaders would yield values", () => {
|
||||||
|
// THE gate this module exists for: a caller cannot leak the user's
|
||||||
|
// agent-memory into a background/knowledge agent's prompt by
|
||||||
|
// forgetting a trait check — the check lives here.
|
||||||
|
for (const agentId of ["background-task-agent", "note_creation", "some-user-agent"]) {
|
||||||
|
expect(loadWorkspaceContext(agentId, "chat-1", loaders)).toEqual({
|
||||||
|
agentNotesContext: null,
|
||||||
|
userWorkDir: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(loadWorkspaceContext(null, "chat-1", loaders).agentNotesContext).toBeNull();
|
||||||
|
expect(loadWorkspaceContext(undefined, "chat-1", loaders).agentNotesContext).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the work-dir lookup without a key", () => {
|
||||||
|
expect(loadWorkspaceContext("copilot", null, loaders)).toEqual({
|
||||||
|
agentNotesContext: "# Agent Memory\n\nnotes",
|
||||||
|
userWorkDir: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
115
apps/x/packages/core/src/agents/workspace-context.ts
Normal file
115
apps/x/packages/core/src/agents/workspace-context.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { WorkDir } from "../config/config.js";
|
||||||
|
import { hasWorkspaceContext } from "./registry.js";
|
||||||
|
|
||||||
|
// Workspace context for agent assembly: agent notes (global) and the user's
|
||||||
|
// per-chat work directory. loadWorkspaceContext is THE chokepoint — the
|
||||||
|
// workspaceContext trait gate lives inside it, so no assembly site can
|
||||||
|
// forget the gate and leak the user's agent-memory into a non-workspace
|
||||||
|
// agent's prompt (or silently omit it for the copilot). Loaders extracted
|
||||||
|
// verbatim from the legacy runtime file.
|
||||||
|
|
||||||
|
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||||
|
|
||||||
|
// Work directory is scoped per run (per chat). Each run gets its own sidecar
|
||||||
|
// config file so setting it in one chat does not leak into others.
|
||||||
|
function workDirConfigFile(runId: string): string {
|
||||||
|
return path.join(WorkDir, 'config', `workdir-${runId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadUserWorkDir(runId: string): string | null {
|
||||||
|
try {
|
||||||
|
const file = workDirConfigFile(runId);
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
const raw = fs.readFileSync(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAgentNotesContext(): string | null {
|
||||||
|
const sections: string[] = [];
|
||||||
|
|
||||||
|
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
|
||||||
|
const prefsFile = path.join(AGENT_NOTES_DIR, 'preferences.md');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(userFile)) {
|
||||||
|
const content = fs.readFileSync(userFile, 'utf-8').trim();
|
||||||
|
if (content) {
|
||||||
|
sections.push(`## About the User\nThese are notes you took about the user in previous chats.\n\n${content}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(prefsFile)) {
|
||||||
|
const content = fs.readFileSync(prefsFile, 'utf-8').trim();
|
||||||
|
if (content) {
|
||||||
|
sections.push(`## User Preferences\nThese are notes you took on their general preferences.\n\n${content}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// List other Agent Notes files for on-demand access
|
||||||
|
const otherFiles: string[] = [];
|
||||||
|
const skipFiles = new Set(['user.md', 'preferences.md', 'inbox.md']);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(AGENT_NOTES_DIR)) {
|
||||||
|
function listMdFiles(dir: string, prefix: string) {
|
||||||
|
for (const entry of fs.readdirSync(dir)) {
|
||||||
|
const fullPath = path.join(dir, entry);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
listMdFiles(fullPath, `${prefix}${entry}/`);
|
||||||
|
} else if (entry.endsWith('.md') && !skipFiles.has(`${prefix}${entry}`)) {
|
||||||
|
otherFiles.push(`${prefix}${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listMdFiles(AGENT_NOTES_DIR, '');
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
if (otherFiles.length > 0) {
|
||||||
|
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) return null;
|
||||||
|
return `# Agent Memory\n\n${sections.join('\n\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceContext {
|
||||||
|
agentNotesContext: string | null;
|
||||||
|
userWorkDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_WORKSPACE: WorkspaceContext = {
|
||||||
|
agentNotesContext: null,
|
||||||
|
userWorkDir: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// workDirKey is the per-chat work-directory sidecar key: the composition's
|
||||||
|
// workDirId on the turn runtime, the runId on the legacy engine.
|
||||||
|
export function loadWorkspaceContext(
|
||||||
|
agentId: string | null | undefined,
|
||||||
|
workDirKey: string | null | undefined,
|
||||||
|
loaders: {
|
||||||
|
loadNotes?: typeof loadAgentNotesContext;
|
||||||
|
loadWorkDir?: typeof loadUserWorkDir;
|
||||||
|
} = {},
|
||||||
|
): WorkspaceContext {
|
||||||
|
if (!hasWorkspaceContext(agentId)) {
|
||||||
|
return NO_WORKSPACE;
|
||||||
|
}
|
||||||
|
const loadNotes = loaders.loadNotes ?? loadAgentNotesContext;
|
||||||
|
const loadWorkDir = loaders.loadWorkDir ?? loadUserWorkDir;
|
||||||
|
return {
|
||||||
|
agentNotesContext: loadNotes(),
|
||||||
|
userWorkDir: workDirKey ? loadWorkDir(workDirKey) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,8 @@ import { composeSystemInstructions } from "../../agents/compose-instructions.js"
|
||||||
import {
|
import {
|
||||||
loadAgentNotesContext,
|
loadAgentNotesContext,
|
||||||
loadUserWorkDir,
|
loadUserWorkDir,
|
||||||
} from "../../agents/runtime.js";
|
loadWorkspaceContext,
|
||||||
|
} from "../../agents/workspace-context.js";
|
||||||
import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js";
|
import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js";
|
||||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||||
import { skillToolNames } from "../../application/assistant/skills/index.js";
|
import { skillToolNames } from "../../application/assistant/skills/index.js";
|
||||||
|
|
@ -126,14 +127,16 @@ export class RealAgentResolver {
|
||||||
? parsed.data
|
? parsed.data
|
||||||
: CompositionOverrides.parse({});
|
: CompositionOverrides.parse({});
|
||||||
const { workDirId, subagent, activeSkills, ...modeFlags } = composition;
|
const { workDirId, subagent, activeSkills, ...modeFlags } = composition;
|
||||||
// Agent notes and work-dir context are scoped to agents with the
|
// The workspaceContext trait gate lives INSIDE loadWorkspaceContext
|
||||||
// workspaceContext trait (the copilot), per the agent registry.
|
// (agents/workspace-context.ts) — no assembly site can forget it.
|
||||||
|
const workspace = loadWorkspaceContext(requested.agentId, workDirId, {
|
||||||
|
loadNotes: this.loadNotes,
|
||||||
|
loadWorkDir: this.loadWorkDir,
|
||||||
|
});
|
||||||
const copilotContext = hasWorkspaceContext(requested.agentId);
|
const copilotContext = hasWorkspaceContext(requested.agentId);
|
||||||
const systemPrompt = composeSystemInstructions({
|
const systemPrompt = composeSystemInstructions({
|
||||||
instructions: agent.instructions,
|
instructions: agent.instructions,
|
||||||
agentNotesContext: copilotContext ? this.loadNotes() : null,
|
...workspace,
|
||||||
userWorkDir:
|
|
||||||
copilotContext && workDirId ? this.loadWorkDir(workDirId) : null,
|
|
||||||
...modeFlags,
|
...modeFlags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue