This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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 ONLY to a per-task `runtimeDir` — never the space-shared
|
||||
* `workspacePath/logs` fallback — so a tool call can never read another task's
|
||||
* logs. When the transcript path is unset the transcript source yields nothing
|
||||
* (comments still work); when the whole IO is unset (subtasks / 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 './core.js';
|
||||
|
||||
const SEARCH_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'SearchTaskConversation',
|
||||
description:
|
||||
'このタスクの過去の会話 (コメント + transcript) をキーワードで検索し、出典 ref 付きの短い抜粋を返す。長い/継続タスクでユーザーに聞き直す前に過去の制約・判断を思い出すために使う。詳細は ReadToolDoc({ name: "SearchTaskConversation" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: '検索キーワード (部分一致・大文字小文字無視)。' },
|
||||
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:<id> または transcript:<index>) の前後を数件だけ読み、当時の文脈を確認する。詳細は 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<string, ToolDef> = {
|
||||
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 collapse(s: string): string {
|
||||
return s.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Compact excerpt centered on the first query hit, bounded to `max` chars. */
|
||||
function excerpt(body: string, query: string, max: number): string {
|
||||
const flat = collapse(body);
|
||||
if (flat.length <= max && !query) return flat;
|
||||
const idx = query ? flat.toLowerCase().indexOf(query.toLowerCase()) : 0;
|
||||
if (idx < 0 || flat.length <= max) {
|
||||
return flat.length <= max ? flat : flat.slice(0, max - 1) + '…';
|
||||
}
|
||||
const start = Math.max(0, idx - 40);
|
||||
const end = Math.min(flat.length, start + max);
|
||||
return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : '');
|
||||
}
|
||||
|
||||
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<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
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 q = query.toLowerCase();
|
||||
|
||||
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 (!c.body.toLowerCase().includes(q)) 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 (!e.content.toLowerCase().includes(q)) 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, query, EXCERPT_MAX)}`);
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('前後の文脈は ReadTaskConversation({ ref }) で確認できます。');
|
||||
return { output: lines.join('\n'), isError: false };
|
||||
}
|
||||
|
||||
async function read(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
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:<id>" または "transcript:<index>" を指定してください。', 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<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult | null> {
|
||||
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 };
|
||||
Reference in New Issue
Block a user