/** * Conversation recall tools — SearchTaskConversation / ReadTaskConversation. * * META tools (always available) that let the agent retrieve older parts of the * CURRENT task's conversation instead of asking the user to repeat themselves * or stuffing the whole transcript into prompt context. * * Sources: * - DB local task comments (user requests / interjections / agent progress), * via the per-task `listComments()` accessor. * - `transcript.jsonl` (the raw ReAct thread), read line-by-line. * * Scoping is enforced by the bound `ctx.taskConversation` IO: comments come from * `listLocalTaskComments(taskId)` (WHERE task_id = ?), and the transcript path is * bound by the worker to a location a tool call can never use to read another * task's logs. For a top-level local task that is the per-task `runtimeDir` * (never the space-shared `workspacePath/logs` fallback). A sub-task run instead * gets a transcript-only IO (no comments — see `makeTranscriptOnlyConversationIO`) * bound to the sub-task's OWN isolated workspace transcript. When the transcript * path is unset the transcript source yields nothing (comments still work); when * the whole IO is unset (gitea-issue runs) the tools degrade to a friendly * non-error message. */ import { existsSync, readFileSync } from 'node:fs'; import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult, TaskConversationComment } from './tool-context.js'; import { excerpt, tokenize } from './text-excerpt.js'; const SEARCH_DEF: ToolDef = { type: 'function', function: { name: 'SearchTaskConversation', description: 'このタスクの過去の会話 (コメント + transcript) をキーワードで検索し、出典 ref 付きの短い抜粋を返す。長い/継続タスクでユーザーに聞き直す前に過去の制約・判断を思い出すために使う。詳細は ReadToolDoc({ name: "SearchTaskConversation" })。', parameters: { type: 'object', properties: { query: { type: 'string', description: '検索キーワード。スペース区切りで複数指定すると AND 検索 (すべての語を含む発言にヒット)。部分一致・大文字小文字無視。' }, source: { type: 'string', enum: ['comments', 'transcript', 'both'], description: '検索対象。既定 both。' }, author: { type: 'string', enum: ['user', 'agent', 'system'], description: '発言者で絞り込む。' }, kind: { type: 'string', description: 'コメント種別で絞り込む (request/comment/interjection/result/ask/progress/handoff)。transcript には適用されない。' }, limit: { type: 'number', description: '最大件数。既定 10、最大 50。' }, }, required: ['query'], }, }, }; const READ_DEF: ToolDef = { type: 'function', function: { name: 'ReadTaskConversation', description: 'SearchTaskConversation が返した ref (comment: または transcript:) の前後を数件だけ読み、当時の文脈を確認する。詳細は ReadToolDoc({ name: "ReadTaskConversation" })。', parameters: { type: 'object', properties: { ref: { type: 'string', description: '"comment:123" または "transcript:42"。' }, before: { type: 'number', description: '前に含める件数。既定 2、最大 5。' }, after: { type: 'number', description: '後に含める件数。既定 2、最大 5。' }, }, required: ['ref'], }, }, }; export const TOOL_DEFS: Record = { SearchTaskConversation: SEARCH_DEF, ReadTaskConversation: READ_DEF, }; const NO_CONTEXT_MSG = '会話検索はこのコンテキストでは利用できません (subtask など local_task と紐付かない実行)。現在見えている文脈で判断してください。'; const EXCERPT_MAX = 200; const READ_EXCERPT_MAX = 500; type Author = 'user' | 'agent' | 'system'; interface TranscriptEntry { index: number; author: Author; content: string; } /** Map a transcript message role to a coarse author bucket. */ function roleToAuthor(role: unknown): Author { if (role === 'user') return 'user'; if (role === 'assistant' || role === 'tool') return 'agent'; return 'system'; } function loadTranscript(path: string | undefined): TranscriptEntry[] { if (!path || !existsSync(path)) return []; const out: TranscriptEntry[] = []; const lines = readFileSync(path, 'utf8').split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (!line.trim()) continue; try { const parsed = JSON.parse(line) as { role?: unknown; content?: unknown }; const content = typeof parsed.content === 'string' ? parsed.content : ''; out.push({ index: i, author: roleToAuthor(parsed.role), content }); } catch { /* skip malformed line */ } } return out; } function clampLimit(v: unknown, def: number, max: number): number { const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def; return Math.min(Math.max(n, 1), max); } function clampNeighbors(v: unknown, def: number, max: number): number { const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def; return Math.min(Math.max(n, 0), max); } async function search(input: Record, ctx: ToolContext): Promise { const io = ctx.taskConversation; if (!io) return { output: NO_CONTEXT_MSG, isError: false }; const query = typeof input['query'] === 'string' ? (input['query'] as string) : ''; if (!query.trim()) { return { output: 'query が空です。検索キーワードを指定してください。', isError: true }; } const source = (input['source'] as string) ?? 'both'; const author = input['author'] as Author | undefined; const kind = typeof input['kind'] === 'string' ? (input['kind'] as string) : undefined; const limit = clampLimit(input['limit'], 10, 50); const tokens = tokenize(query); const matches = (body: string): boolean => { const lower = body.toLowerCase(); return tokens.every((t) => lower.includes(t)); }; type Hit = { ref: string; header: string; body: string }; const hits: Hit[] = []; if (source === 'comments' || source === 'both') { const comments = await io.listComments(); for (const c of comments) { if (author && c.author !== author) continue; if (kind && c.kind !== kind) continue; if (!matches(c.body)) continue; hits.push({ ref: `comment:${c.id}`, header: `${c.author}/${c.kind} ${c.createdAt}`, body: c.body }); } } if (source === 'transcript' || source === 'both') { // kind is a comment concept; transcript entries have none, so a kind filter // excludes the transcript source entirely. if (!kind) { for (const e of loadTranscript(io.transcriptPath)) { if (author && e.author !== author) continue; if (!matches(e.content)) continue; hits.push({ ref: `transcript:${e.index}`, header: e.author, body: e.content }); } } } if (hits.length === 0) { return { output: `## Conversation Search Results\n\n"${query}" に一致する会話は見つかりませんでした。`, isError: false }; } const shown = hits.slice(0, limit); const lines = [`## Conversation Search Results (query: "${query}", ${hits.length} hit(s)${hits.length > limit ? `, showing ${limit}` : ''})`, '']; for (const h of shown) { lines.push(`- ${h.ref} ${h.header}`); lines.push(` ${excerpt(h.body, tokens, EXCERPT_MAX)}`); lines.push(''); } lines.push('前後の文脈は ReadTaskConversation({ ref }) で確認できます。'); return { output: lines.join('\n'), isError: false }; } async function read(input: Record, ctx: ToolContext): Promise { const io = ctx.taskConversation; if (!io) return { output: NO_CONTEXT_MSG, isError: false }; const ref = typeof input['ref'] === 'string' ? (input['ref'] as string) : ''; const m = /^(comment|transcript):(\d+)$/.exec(ref.trim()); if (!m) { return { output: 'ref が不正です。"comment:" または "transcript:" を指定してください。', isError: true }; } const kind = m[1] as 'comment' | 'transcript'; const id = Number(m[2]); const before = clampNeighbors(input['before'], 2, 5); const after = clampNeighbors(input['after'], 2, 5); if (kind === 'comment') { const comments = await io.listComments(); const pos = comments.findIndex((c) => c.id === id); if (pos < 0) return { output: `comment:${id} は見つかりませんでした。`, isError: true }; const slice = comments.slice(Math.max(0, pos - before), pos + after + 1); const lines = [`## ReadTaskConversation — comment #${id} (前 ${before} / 後 ${after})`, '']; for (const c of slice) { const marker = c.id === id ? ' ←' : ''; lines.push(`- comment:${c.id} ${c.author}/${c.kind} ${c.createdAt}${marker}`); lines.push(` ${excerpt(c.body, [], READ_EXCERPT_MAX)}`); } return { output: lines.join('\n'), isError: false }; } const entries = loadTranscript(io.transcriptPath); const pos = entries.findIndex((e) => e.index === id); if (pos < 0) return { output: `transcript:${id} は見つかりませんでした。`, isError: true }; const slice = entries.slice(Math.max(0, pos - before), pos + after + 1); const lines = [`## ReadTaskConversation — transcript #${id} (前 ${before} / 後 ${after})`, '']; for (const e of slice) { const marker = e.index === id ? ' ←' : ''; lines.push(`- transcript:${e.index} ${e.author}${marker}`); lines.push(` ${excerpt(e.content, [], READ_EXCERPT_MAX)}`); } return { output: lines.join('\n'), isError: false }; } export async function executeTool( name: string, input: Record, ctx: ToolContext, ): Promise { if (name === 'SearchTaskConversation') return search(input, ctx); if (name === 'ReadTaskConversation') return read(input, ctx); return null; } // Keep the type import referenced for consumers of this module's typings. export type { TaskConversationComment };