mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +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 { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { hasWorkspaceContext, loadAgent } from "./registry.js";
|
||||
import { loadWorkspaceContext } from "./workspace-context.js";
|
||||
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.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 { 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>;
|
||||
|
||||
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 {
|
||||
return now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
|
|
@ -1312,11 +1240,11 @@ export async function* streamAgent({
|
|||
loopLogger.log('running llm turn');
|
||||
// stream agent response and build message
|
||||
const messageBuilder = new StreamStepMessageBuilder();
|
||||
const composeCopilotContext = hasWorkspaceContext(state.agentName);
|
||||
const workspace = loadWorkspaceContext(state.agentName, runId);
|
||||
const instructionsWithDateTime = composeSystemInstructions({
|
||||
instructions: agent.instructions,
|
||||
agentNotesContext: composeCopilotContext ? loadAgentNotesContext() : null,
|
||||
userWorkDir: composeCopilotContext ? loadUserWorkDir(runId) : null,
|
||||
agentNotesContext: workspace.agentNotesContext,
|
||||
userWorkDir: workspace.userWorkDir,
|
||||
voiceInput,
|
||||
voiceOutput,
|
||||
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 {
|
||||
loadAgentNotesContext,
|
||||
loadUserWorkDir,
|
||||
} from "../../agents/runtime.js";
|
||||
loadWorkspaceContext,
|
||||
} from "../../agents/workspace-context.js";
|
||||
import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js";
|
||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
import { skillToolNames } from "../../application/assistant/skills/index.js";
|
||||
|
|
@ -126,14 +127,16 @@ export class RealAgentResolver {
|
|||
? parsed.data
|
||||
: CompositionOverrides.parse({});
|
||||
const { workDirId, subagent, activeSkills, ...modeFlags } = composition;
|
||||
// Agent notes and work-dir context are scoped to agents with the
|
||||
// workspaceContext trait (the copilot), per the agent registry.
|
||||
// The workspaceContext trait gate lives INSIDE loadWorkspaceContext
|
||||
// (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 systemPrompt = composeSystemInstructions({
|
||||
instructions: agent.instructions,
|
||||
agentNotesContext: copilotContext ? this.loadNotes() : null,
|
||||
userWorkDir:
|
||||
copilotContext && workDirId ? this.loadWorkDir(workDirId) : null,
|
||||
...workspace,
|
||||
...modeFlags,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue