233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
/**
|
|
* 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:<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 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 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<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 };
|