feat(FN-4620): complete Step 4 — add milestone/slice interview board tools
Fusion-Task-Id: FN-4620 Fusion-Task-Lineage: d3d05e54-e137-46ee-bb5d-d71aa03e5a48
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
* - Unified session type for both milestone and slice interviews
|
* - Unified session type for both milestone and slice interviews
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PlanningQuestion, Milestone, Slice, MissionStore, InterviewState, SlicePlanState } from "@fusion/core";
|
import type { PlanningQuestion, Milestone, Slice, MissionStore, InterviewState, SlicePlanState, TaskStore } from "@fusion/core";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
|
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
|
||||||
@@ -96,6 +96,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
|
|||||||
export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse };
|
export { parseTargetInterviewResponseImpl as parseTargetInterviewResponse };
|
||||||
|
|
||||||
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
import { createFnAgent as engineCreateFnAgent } from "@fusion/engine";
|
||||||
|
import { createPlanningBoardTools } from "./planning-board-tools.js";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type AgentResult = any;
|
type AgentResult = any;
|
||||||
@@ -166,6 +167,11 @@ A milestone represents a major phase or deliverable within a larger mission. Eac
|
|||||||
- Milestone: "verification" field — how to confirm this phase is complete
|
- Milestone: "verification" field — how to confirm this phase is complete
|
||||||
- Slice: "verification" field — how to confirm this work unit is done
|
- Slice: "verification" field — how to confirm this work unit is done
|
||||||
|
|
||||||
|
## Board tools
|
||||||
|
- fn_task_list — list active tasks
|
||||||
|
- fn_task_get — read a task's full details and PROMPT.md
|
||||||
|
Use these to avoid duplicating an existing in-flight plan and to anchor your questions against current backlog context.
|
||||||
|
|
||||||
## Response Format
|
## Response Format
|
||||||
Always respond with valid JSON in one of these formats:
|
Always respond with valid JSON in one of these formats:
|
||||||
|
|
||||||
@@ -210,6 +216,11 @@ A slice represents a focused work unit within a milestone that can be activated
|
|||||||
- Slice: "verification" field — how to confirm this work unit is done
|
- Slice: "verification" field — how to confirm this work unit is done
|
||||||
- Feature: "acceptanceCriteria" field — how to verify this specific deliverable
|
- Feature: "acceptanceCriteria" field — how to verify this specific deliverable
|
||||||
|
|
||||||
|
## Board tools
|
||||||
|
- fn_task_list — list active tasks
|
||||||
|
- fn_task_get — read a task's full details and PROMPT.md
|
||||||
|
Use these to avoid duplicating an existing in-flight plan and to anchor your questions against current backlog context.
|
||||||
|
|
||||||
## Response Format
|
## Response Format
|
||||||
Always respond with valid JSON in one of these formats:
|
Always respond with valid JSON in one of these formats:
|
||||||
|
|
||||||
@@ -717,6 +728,7 @@ function getSystemPrompt(targetType: TargetType): string {
|
|||||||
async function createTargetInterviewAgent(
|
async function createTargetInterviewAgent(
|
||||||
session: TargetInterviewSession,
|
session: TargetInterviewSession,
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
|
store: TaskStore,
|
||||||
): Promise<AgentResult> {
|
): Promise<AgentResult> {
|
||||||
await ensureEngineReady();
|
await ensureEngineReady();
|
||||||
|
|
||||||
@@ -724,6 +736,7 @@ async function createTargetInterviewAgent(
|
|||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
systemPrompt: getSystemPrompt(session.targetType),
|
systemPrompt: getSystemPrompt(session.targetType),
|
||||||
tools: "readonly",
|
tools: "readonly",
|
||||||
|
customTools: [...createPlanningBoardTools(store)],
|
||||||
onThinking: (delta: string) => {
|
onThinking: (delta: string) => {
|
||||||
session.thinkingOutput += delta;
|
session.thinkingOutput += delta;
|
||||||
persistThinking(session.id, session.thinkingOutput);
|
persistThinking(session.id, session.thinkingOutput);
|
||||||
@@ -771,6 +784,7 @@ function formatInterviewHistory(
|
|||||||
async function ensureInterviewAgent(
|
async function ensureInterviewAgent(
|
||||||
session: TargetInterviewSession,
|
session: TargetInterviewSession,
|
||||||
rootDir: string | undefined,
|
rootDir: string | undefined,
|
||||||
|
store: TaskStore | undefined,
|
||||||
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (session.agent) {
|
if (session.agent) {
|
||||||
@@ -783,7 +797,13 @@ async function ensureInterviewAgent(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
session.agent = await createTargetInterviewAgent(session, rootDir);
|
if (!store) {
|
||||||
|
throw new TargetInvalidSessionStateError(
|
||||||
|
"AI agent not available for this session and cannot be resumed without task store context",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.agent = await createTargetInterviewAgent(session, rootDir, store);
|
||||||
|
|
||||||
if (historyForReplay.length === 0) {
|
if (historyForReplay.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -820,9 +840,9 @@ async function ensureInterviewAgent(
|
|||||||
/**
|
/**
|
||||||
* Initialize the AI agent for a session and start the first turn.
|
* Initialize the AI agent for a session and start the first turn.
|
||||||
*/
|
*/
|
||||||
async function initializeAgent(session: TargetInterviewSession, rootDir: string): Promise<void> {
|
async function initializeAgent(session: TargetInterviewSession, rootDir: string, store: TaskStore): Promise<void> {
|
||||||
try {
|
try {
|
||||||
session.agent = await createTargetInterviewAgent(session, rootDir);
|
session.agent = await createTargetInterviewAgent(session, rootDir, store);
|
||||||
session.updatedAt = new Date();
|
session.updatedAt = new Date();
|
||||||
|
|
||||||
// Send initial message to get first question
|
// Send initial message to get first question
|
||||||
@@ -1002,7 +1022,8 @@ export async function createTargetInterviewSession(
|
|||||||
targetId: string,
|
targetId: string,
|
||||||
targetTitle: string,
|
targetTitle: string,
|
||||||
missionContext: string | undefined,
|
missionContext: string | undefined,
|
||||||
rootDir: string
|
rootDir: string,
|
||||||
|
store: TaskStore,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!checkRateLimit(ip)) {
|
if (!checkRateLimit(ip)) {
|
||||||
const resetTime = getRateLimitResetTime(ip);
|
const resetTime = getRateLimitResetTime(ip);
|
||||||
@@ -1032,7 +1053,7 @@ export async function createTargetInterviewSession(
|
|||||||
persistSession(session, "generating");
|
persistSession(session, "generating");
|
||||||
|
|
||||||
// Initialize AI agent in background
|
// Initialize AI agent in background
|
||||||
initializeAgent(session, rootDir).catch((err) => {
|
initializeAgent(session, rootDir, store).catch((err) => {
|
||||||
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
|
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
|
||||||
persistSession(session, "error", err.message || "Failed to initialize AI agent");
|
persistSession(session, "error", err.message || "Failed to initialize AI agent");
|
||||||
milestoneSliceInterviewStreamManager.broadcast(sessionId, {
|
milestoneSliceInterviewStreamManager.broadcast(sessionId, {
|
||||||
@@ -1051,6 +1072,7 @@ export async function submitTargetInterviewResponse(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
responses: Record<string, unknown>,
|
responses: Record<string, unknown>,
|
||||||
rootDir?: string,
|
rootDir?: string,
|
||||||
|
store?: TaskStore,
|
||||||
): Promise<TargetInterviewResponse> {
|
): Promise<TargetInterviewResponse> {
|
||||||
const session = getTargetInterviewSession(sessionId);
|
const session = getTargetInterviewSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -1072,7 +1094,7 @@ export async function submitTargetInterviewResponse(
|
|||||||
|
|
||||||
if (!session.agent) {
|
if (!session.agent) {
|
||||||
const replayHistory = session.history.slice(0, -1);
|
const replayHistory = session.history.slice(0, -1);
|
||||||
await ensureInterviewAgent(session, rootDir, replayHistory);
|
await ensureInterviewAgent(session, rootDir, store, replayHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||||
@@ -1099,7 +1121,7 @@ export async function submitTargetInterviewResponse(
|
|||||||
/**
|
/**
|
||||||
* Retry a failed interview session.
|
* Retry a failed interview session.
|
||||||
*/
|
*/
|
||||||
export async function retryTargetInterviewSession(sessionId: string, rootDir: string): Promise<void> {
|
export async function retryTargetInterviewSession(sessionId: string, rootDir: string, store?: TaskStore): Promise<void> {
|
||||||
const session = getTargetInterviewSession(sessionId);
|
const session = getTargetInterviewSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`);
|
throw new TargetSessionNotFoundError(`Interview session ${sessionId} not found or expired`);
|
||||||
@@ -1126,7 +1148,7 @@ export async function retryTargetInterviewSession(sessionId: string, rootDir: st
|
|||||||
persistSession(session, "generating");
|
persistSession(session, "generating");
|
||||||
|
|
||||||
if (session.history.length === 0) {
|
if (session.history.length === 0) {
|
||||||
await ensureInterviewAgent(session, rootDir, []);
|
await ensureInterviewAgent(session, rootDir, store, []);
|
||||||
await continueAgentConversation(
|
await continueAgentConversation(
|
||||||
session,
|
session,
|
||||||
`I want to refine the scope for this ${session.targetType}: "${session.targetTitle}".` +
|
`I want to refine the scope for this ${session.targetType}: "${session.targetTitle}".` +
|
||||||
@@ -1139,7 +1161,7 @@ export async function retryTargetInterviewSession(sessionId: string, rootDir: st
|
|||||||
const replayHistory = session.history.slice(0, -1);
|
const replayHistory = session.history.slice(0, -1);
|
||||||
const lastEntry = session.history[session.history.length - 1];
|
const lastEntry = session.history[session.history.length - 1];
|
||||||
|
|
||||||
await ensureInterviewAgent(session, rootDir, replayHistory);
|
await ensureInterviewAgent(session, rootDir, store, replayHistory);
|
||||||
const replayMessage = formatResponseForAgent(
|
const replayMessage = formatResponseForAgent(
|
||||||
lastEntry.question,
|
lastEntry.question,
|
||||||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
||||||
|
|||||||
@@ -2930,7 +2930,8 @@ export function createMissionRouter(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
const rootDir = await getRootDirForRequest(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const rootDir = scopedStore.getRootDir();
|
||||||
|
|
||||||
// Get mission context for the interview
|
// Get mission context for the interview
|
||||||
const mission = missionStore.getMission(milestone.missionId);
|
const mission = missionStore.getMission(milestone.missionId);
|
||||||
@@ -2946,7 +2947,8 @@ export function createMissionRouter(
|
|||||||
milestoneId,
|
milestoneId,
|
||||||
milestone.title,
|
milestone.title,
|
||||||
missionContext,
|
missionContext,
|
||||||
rootDir
|
rootDir,
|
||||||
|
scopedStore,
|
||||||
);
|
);
|
||||||
res.status(201).json({ sessionId });
|
res.status(201).json({ sessionId });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -2998,8 +3000,9 @@ export function createMissionRouter(
|
|||||||
try {
|
try {
|
||||||
const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js");
|
const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js");
|
||||||
|
|
||||||
const rootDir = await getRootDirForRequest(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir);
|
const rootDir = scopedStore.getRootDir();
|
||||||
|
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errName = err instanceof Error ? err.name : "";
|
const errName = err instanceof Error ? err.name : "";
|
||||||
@@ -3161,8 +3164,9 @@ export function createMissionRouter(
|
|||||||
try {
|
try {
|
||||||
const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js");
|
const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js");
|
||||||
|
|
||||||
const rootDir = await getRootDirForRequest(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
await retryTargetInterviewSession(sessionId, rootDir);
|
const rootDir = scopedStore.getRootDir();
|
||||||
|
await retryTargetInterviewSession(sessionId, rootDir, scopedStore);
|
||||||
res.json({ success: true, sessionId });
|
res.json({ success: true, sessionId });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errName = err instanceof Error ? err.name : "";
|
const errName = err instanceof Error ? err.name : "";
|
||||||
@@ -3271,7 +3275,8 @@ export function createMissionRouter(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
const rootDir = await getRootDirForRequest(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const rootDir = scopedStore.getRootDir();
|
||||||
|
|
||||||
// Get mission hierarchy context for the interview
|
// Get mission hierarchy context for the interview
|
||||||
const milestone = missionStore.getMilestone(slice.milestoneId);
|
const milestone = missionStore.getMilestone(slice.milestoneId);
|
||||||
@@ -3290,7 +3295,8 @@ export function createMissionRouter(
|
|||||||
sliceId,
|
sliceId,
|
||||||
slice.title,
|
slice.title,
|
||||||
missionContext,
|
missionContext,
|
||||||
rootDir
|
rootDir,
|
||||||
|
scopedStore,
|
||||||
);
|
);
|
||||||
res.status(201).json({ sessionId });
|
res.status(201).json({ sessionId });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -3342,8 +3348,9 @@ export function createMissionRouter(
|
|||||||
try {
|
try {
|
||||||
const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js");
|
const { submitTargetInterviewResponse } = await import("./milestone-slice-interview.js");
|
||||||
|
|
||||||
const rootDir = await getRootDirForRequest(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir);
|
const rootDir = scopedStore.getRootDir();
|
||||||
|
const result = await submitTargetInterviewResponse(sessionId, responses, rootDir, scopedStore);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errName = err instanceof Error ? err.name : "";
|
const errName = err instanceof Error ? err.name : "";
|
||||||
@@ -3505,8 +3512,9 @@ export function createMissionRouter(
|
|||||||
try {
|
try {
|
||||||
const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js");
|
const { retryTargetInterviewSession } = await import("./milestone-slice-interview.js");
|
||||||
|
|
||||||
const rootDir = await getRootDirForRequest(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
await retryTargetInterviewSession(sessionId, rootDir);
|
const rootDir = scopedStore.getRootDir();
|
||||||
|
await retryTargetInterviewSession(sessionId, rootDir, scopedStore);
|
||||||
res.json({ success: true, sessionId });
|
res.json({ success: true, sessionId });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errName = err instanceof Error ? err.name : "";
|
const errName = err instanceof Error ? err.name : "";
|
||||||
|
|||||||
Reference in New Issue
Block a user