maestro/src/engine/local-context.ts
oss-sync b857c33ef6
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (f6d625db)
2026-06-26 03:35:45 +00:00

86 lines
4.0 KiB
TypeScript

import type { LocalTaskComment } from '../db/repository.js';
/** Comment kinds that represent a direct user instruction (vs. agent progress/result/ask rows). */
export const USER_INSTRUCTION_KINDS = ['comment', 'request', 'interjection'] as const;
export interface LocalContextInput {
/** All comments for the task, oldest-first (as returned by listLocalTaskComments). */
comments: LocalTaskComment[];
/** The job's original instruction (task body). */
jobInstruction: string;
/** Filenames under input/ (display only). */
inputFiles: string[];
/** Filenames under output/ (display only). */
outputFiles: string[];
/** When true (continuation jobs that replay the full transcript), skip the
* "## これまでのやり取り" recap — the replayed real turns supersede it. */
omitRecentConversation?: boolean;
}
function isUserInstruction(c: LocalTaskComment): boolean {
return c.author === 'user' && (USER_INSTRUCTION_KINDS as readonly string[]).includes(c.kind);
}
/**
* Build the conversation-context block injected into a (re)started local-task job.
*
* Two behaviors matter when a task is cancelled mid-loop and re-run:
* - Interjections (kind='interjection', sent while the task was running) count as
* user instructions, so the LATEST one becomes the "current instruction" — not just
* the original task body. Without this, a resumed agent loses what the user actually
* asked for during the loop.
* - The "recent conversation" window is the last 10 comments, but a busy loop floods the
* task with agent `progress` rows that would otherwise push the user's message out of
* that window. We therefore guarantee the last few USER messages are always shown.
*
* Returns the context body (without the time-context prefix, which the caller prepends).
*/
export function buildLocalConversationContext(input: LocalContextInput): string {
const { comments, jobInstruction, inputFiles, outputFiles } = input;
const contextParts: string[] = [];
// The active instruction is the LATEST user message (comment/request/interjection),
// skipping agent progress/result/ask rows. The original task body stays as reference.
const latestUserComment = [...comments].reverse().find(isUserInstruction);
const currentInstructionBody =
latestUserComment && latestUserComment.body.trim() !== jobInstruction.trim()
? latestUserComment.body
: jobInstruction;
const hasFollowUp = currentInstructionBody !== jobInstruction;
// Recent conversation: last 10 overall, but never drop the last 5 user messages —
// otherwise a flood of agent progress rows hides what the user said.
const recentOverall = comments.slice(-10);
const recentUserMsgs = comments.filter(isUserInstruction).slice(-5);
const displayMap = new Map<number, LocalTaskComment>();
for (const c of [...recentOverall, ...recentUserMsgs]) displayMap.set(c.id, c);
const display = [...displayMap.values()].sort((a, b) => a.id - b.id);
if (!input.omitRecentConversation && display.length > 0) {
contextParts.push('## これまでのやり取り');
for (const comment of display) {
const truncated = comment.body.length > 500 ? comment.body.slice(0, 500) + '...' : comment.body;
contextParts.push(`[${comment.author}/${comment.kind}] ${truncated}`);
}
contextParts.push('');
}
contextParts.push('## ワークスペース状況');
if (inputFiles.length > 0) contextParts.push(`input/: ${inputFiles.join(', ')}`);
if (outputFiles.length > 0) contextParts.push(`output/: ${outputFiles.join(', ')}`);
contextParts.push('');
if (hasFollowUp) {
contextParts.push('## オリジナルタスク (参考、対応済みの可能性あり)');
contextParts.push(jobInstruction);
contextParts.push('');
contextParts.push('## 現在のユーザー指示 (これに対応する)');
contextParts.push(currentInstructionBody);
} else {
contextParts.push('## タスク');
contextParts.push(jobInstruction);
}
return contextParts.join('\n');
}