This commit is contained in:
@@ -28,6 +28,8 @@ const DOCS_DIR = path.join(REPO_ROOT, 'docs', 'tools');
|
||||
// 関連ツールが同じ doc を参照できるようエイリアスを定義
|
||||
// キー・値ともに小文字
|
||||
const TOOL_DOC_ALIASES: Record<string, string> = {
|
||||
// updateuseragents.md にまとめる
|
||||
readuseragents: 'updateuseragents',
|
||||
// checklist.md にまとめる
|
||||
createchecklist: 'checklist',
|
||||
checkitem: 'checklist',
|
||||
|
||||
@@ -378,10 +378,12 @@ export async function getToolDefs(
|
||||
// - MissionUpdate: タスクの目標 / 進捗のピン止めメモを更新 (会話が長くなって
|
||||
// 最初の要件を見失わないため。常時上書き可能、未指定フィールドは保持)
|
||||
// - ListUserAssets / RunUserScript: ユーザーフォルダのスクリプト探索・実行
|
||||
// - ReadUserAgents / UpdateUserAgents: per-user AGENTS.md (常時指示書) の読み書き
|
||||
// (UpdateUserMemory が事実断片、こちらは振る舞い方針そのもの)
|
||||
// - Brainstorm: 着手前 or 行き詰まり時の多アプローチ比較 (issue #247)
|
||||
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
|
||||
// ただし他の piece からも参照できるようにメタ扱い
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill'];
|
||||
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'ReadUserTemplate', 'RenderUserTemplate', 'WriteUserScript', 'WriteUserTemplate', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill'];
|
||||
const effectiveAllowed = [...allowedTools];
|
||||
for (const meta of META_TOOLS) {
|
||||
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
} from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
@@ -769,3 +771,120 @@ describe('RunUserScript: audit log hook', () => {
|
||||
expect(auditCalls.some(c => c.action === 'user_script_denied')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ReadUserAgents / UpdateUserAgents ─────────────────────────────────────────
|
||||
|
||||
describe('ReadUserAgents / UpdateUserAgents', () => {
|
||||
const agentsPath = () => join(userFolderRoot, TEST_USER, 'AGENTS.md');
|
||||
|
||||
it('ReadUserAgents returns a friendly note when AGENTS.md does not exist', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('ReadUserAgents', {}, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(r?.output).toContain('does not exist yet');
|
||||
});
|
||||
|
||||
it('append creates AGENTS.md when empty', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: '# 方針\n常に日本語で答える' }, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n常に日本語で答える');
|
||||
});
|
||||
|
||||
it('append adds a blank line between existing content and new text', async () => {
|
||||
writeFileSync(agentsPath(), '# 既存\nルール1');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'ルール2' }, ctx);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 既存\nルール1\n\nルール2');
|
||||
});
|
||||
|
||||
it('ReadUserAgents returns the full current content', async () => {
|
||||
writeFileSync(agentsPath(), 'line A\nline B');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('ReadUserAgents', {}, ctx);
|
||||
expect(r?.output).toBe('line A\nline B');
|
||||
});
|
||||
|
||||
it('replace swaps an exact unique match', async () => {
|
||||
writeFileSync(agentsPath(), '# 方針\n古い行\nおわり');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: '古い行', new_text: '新しい行' }, ctx);
|
||||
expect(r?.isError).toBe(false);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('# 方針\n新しい行\nおわり');
|
||||
});
|
||||
|
||||
it('replace does not interpret $-patterns in new_text', async () => {
|
||||
writeFileSync(agentsPath(), 'price: PLACEHOLDER yen');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'PLACEHOLDER', new_text: '$100 & $&' }, ctx);
|
||||
expect(readFileSync(agentsPath(), 'utf-8')).toBe('price: $100 & $& yen');
|
||||
});
|
||||
|
||||
it('replace errors when old_text is not found', async () => {
|
||||
writeFileSync(agentsPath(), 'something');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'missing', new_text: 'x' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('not found');
|
||||
});
|
||||
|
||||
it('replace errors when old_text matches more than once', async () => {
|
||||
writeFileSync(agentsPath(), 'dup\ndup');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'dup', new_text: 'x' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('matches 2 times');
|
||||
});
|
||||
|
||||
it('replace on an empty file is rejected with a hint to append', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'replace', old_text: 'x', new_text: 'y' }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('append');
|
||||
});
|
||||
|
||||
it('snapshots the prior version to trash/agents-history before overwriting', async () => {
|
||||
writeFileSync(agentsPath(), 'original content');
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'more' }, ctx);
|
||||
|
||||
const histDir = join(userFolderRoot, TEST_USER, 'trash', 'agents-history');
|
||||
const backups = readdirSync(histDir).filter((f) => f.startsWith('AGENTS.md.') && f.endsWith('.bak'));
|
||||
expect(backups.length).toBe(1);
|
||||
expect(readFileSync(join(histDir, backups[0]!), 'utf-8')).toBe('original content');
|
||||
});
|
||||
|
||||
it('rejects when result would exceed the 64KB cap', async () => {
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
const big = 'x'.repeat(64 * 1024 + 1);
|
||||
const r = await executeTool('UpdateUserAgents', { mode: 'append', new_text: big }, ctx);
|
||||
expect(r?.isError).toBe(true);
|
||||
expect(r?.output).toContain('exceeds');
|
||||
});
|
||||
|
||||
it('emits a user_agents_updated audit event', async () => {
|
||||
const auditCalls: Array<{ action: string; detail: object }> = [];
|
||||
setUserFolderToolDeps({
|
||||
sessRepo: null as never,
|
||||
masterKeyPath: '',
|
||||
userFolderRoot,
|
||||
auditLog: (action, detail) => auditCalls.push({ action, detail }),
|
||||
});
|
||||
const ctx = buildCtx({ userId: TEST_USER });
|
||||
await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'hello' }, ctx);
|
||||
expect(auditCalls.some((c) => c.action === 'user_agents_updated')).toBe(true);
|
||||
});
|
||||
|
||||
it('both tools require an authenticated user', async () => {
|
||||
const ctx = buildCtx({ userId: undefined });
|
||||
const r1 = await executeTool('ReadUserAgents', {}, ctx);
|
||||
const r2 = await executeTool('UpdateUserAgents', { mode: 'append', new_text: 'x' }, ctx);
|
||||
expect(r1?.isError).toBe(true);
|
||||
expect(r2?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('registers both tools in TOOL_DEFS', () => {
|
||||
expect(TOOL_DEFS['ReadUserAgents']).toBeDefined();
|
||||
expect(TOOL_DEFS['UpdateUserAgents']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
userRoot,
|
||||
assertOwnerAccess,
|
||||
resolveUserSubdir,
|
||||
readUserAgentsMdRaw,
|
||||
writeUserAgentsMd,
|
||||
snapshotUserAgentsMd,
|
||||
} from '../../user-folder/paths.js';
|
||||
import {
|
||||
upsertMemoryEntry,
|
||||
@@ -124,6 +127,48 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
},
|
||||
},
|
||||
|
||||
ReadUserAgents: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ReadUserAgents',
|
||||
description:
|
||||
'Returns the caller\'s full AGENTS.md (per-user standing instructions, auto-injected into every task). Read it before editing. Details via ReadToolDoc({ name: "UpdateUserAgents" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
UpdateUserAgents: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'UpdateUserAgents',
|
||||
description:
|
||||
'Edits the caller\'s AGENTS.md via "replace" (old_text→new_text, must match exactly once) or "append". Snapshots the prior version first. Details via ReadToolDoc({ name: "UpdateUserAgents" }).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['replace', 'append'],
|
||||
description: '"replace" swaps old_text for new_text; "append" adds new_text to the end (also creates the file if empty).',
|
||||
},
|
||||
old_text: {
|
||||
type: 'string',
|
||||
description: 'Exact existing text to replace. Required for "replace"; must appear exactly once (add surrounding context to disambiguate).',
|
||||
},
|
||||
new_text: {
|
||||
type: 'string',
|
||||
description: 'For "replace", the replacement text; for "append", the text to add. Required.',
|
||||
},
|
||||
},
|
||||
required: ['mode', 'new_text'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ListUserAssets: {
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -309,6 +354,111 @@ async function executeReadUserMemory(
|
||||
return { output, isError: false };
|
||||
}
|
||||
|
||||
// ── ReadUserAgents implementation ─────────────────────────────────────────────
|
||||
|
||||
async function executeReadUserAgents(
|
||||
_input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'ReadUserAgents requires an authenticated user', isError: true };
|
||||
}
|
||||
const content = readUserAgentsMdRaw(getUserFolderRoot(), ctx.userId);
|
||||
if (content === null || content.length === 0) {
|
||||
return {
|
||||
output:
|
||||
'AGENTS.md does not exist yet (empty). Use UpdateUserAgents with mode="append" to create it.',
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
return { output: content, isError: false };
|
||||
}
|
||||
|
||||
// ── UpdateUserAgents implementation ───────────────────────────────────────────
|
||||
|
||||
async function executeUpdateUserAgents(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
if (!ctx.userId) {
|
||||
return { output: 'UpdateUserAgents requires an authenticated user', isError: true };
|
||||
}
|
||||
|
||||
const mode = input['mode'];
|
||||
if (mode !== 'replace' && mode !== 'append') {
|
||||
return { output: 'UpdateUserAgents: "mode" must be "replace" or "append"', isError: true };
|
||||
}
|
||||
|
||||
const newText = input['new_text'];
|
||||
if (typeof newText !== 'string') {
|
||||
return { output: 'UpdateUserAgents: "new_text" is required', isError: true };
|
||||
}
|
||||
|
||||
const folderRoot = getUserFolderRoot();
|
||||
const current = readUserAgentsMdRaw(folderRoot, ctx.userId) ?? '';
|
||||
|
||||
let next: string;
|
||||
if (mode === 'replace') {
|
||||
const oldText = input['old_text'];
|
||||
if (typeof oldText !== 'string' || oldText.length === 0) {
|
||||
return { output: 'UpdateUserAgents: "old_text" is required for mode "replace"', isError: true };
|
||||
}
|
||||
if (current.length === 0) {
|
||||
return {
|
||||
output: 'UpdateUserAgents: AGENTS.md is empty — use mode "append" to add content',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const count = current.split(oldText).length - 1;
|
||||
if (count === 0) {
|
||||
return {
|
||||
output:
|
||||
'UpdateUserAgents: "old_text" not found in AGENTS.md. Call ReadUserAgents to see the exact current content.',
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (count > 1) {
|
||||
return {
|
||||
output: `UpdateUserAgents: "old_text" matches ${count} times — include more surrounding context to make it unique.`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// Function replacement avoids `$&`/`$1` being interpreted in new_text.
|
||||
next = current.replace(oldText, () => newText);
|
||||
} else {
|
||||
// append
|
||||
if (newText.length === 0) {
|
||||
return { output: 'UpdateUserAgents: "new_text" must be non-empty for mode "append"', isError: true };
|
||||
}
|
||||
next = current.length === 0 ? newText : `${current.replace(/\n*$/, '')}\n\n${newText}`;
|
||||
}
|
||||
|
||||
// Snapshot the prior version before overwriting (best-effort; missing/empty → null).
|
||||
let backup: string | null = null;
|
||||
try {
|
||||
backup = snapshotUserAgentsMd(folderRoot, ctx.userId);
|
||||
} catch {
|
||||
/* snapshot is best-effort; proceed with the write */
|
||||
}
|
||||
|
||||
try {
|
||||
writeUserAgentsMd(folderRoot, ctx.userId, next);
|
||||
} catch (err) {
|
||||
return { output: `UpdateUserAgents: ${(err as Error).message}`, isError: true };
|
||||
}
|
||||
|
||||
_deps?.auditLog?.(
|
||||
'user_agents_updated',
|
||||
{ userId: ctx.userId, mode, bytes: Buffer.byteLength(next, 'utf-8'), snapshotted: backup !== null },
|
||||
ctx.taskId ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
output: `AGENTS.md updated (mode=${mode}, now ${Buffer.byteLength(next, 'utf-8')} bytes)${backup ? ' — previous version snapshotted to trash/agents-history/' : ''}`,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ListUserAssets implementation ─────────────────────────────────────────────
|
||||
|
||||
async function executeListUserAssets(
|
||||
@@ -626,6 +776,8 @@ export async function executeTool(
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'UpdateUserMemory') return executeUpdateUserMemory(input, ctx);
|
||||
if (name === 'ReadUserMemory') return executeReadUserMemory(input, ctx);
|
||||
if (name === 'ReadUserAgents') return executeReadUserAgents(input, ctx);
|
||||
if (name === 'UpdateUserAgents') return executeUpdateUserAgents(input, ctx);
|
||||
if (name === 'ListUserAssets') return executeListUserAssets(input, ctx);
|
||||
if (name === 'RunUserScript') return executeRunUserScript(input, ctx);
|
||||
if (name === 'WriteUserScript') return executeWriteUserScript(input, ctx);
|
||||
|
||||
Reference in New Issue
Block a user