sync: update from private repo (edc775f2)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-07-06 01:04:12 +00:00
parent 747377bef9
commit b1292e34b2
322 changed files with 28001 additions and 4686 deletions
+241
View File
@@ -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 };