feat: phase 3-5 — workflow engine, background subagents, multi-modal, cache shape, inline diff
Phase 3: Dynamic Workflow Engine - VM sandbox (node:vm) with agent/parallel/pipeline API, Claude Code compatible - Workflow file discovery (.boocode/workflows/*.js + ~/.boocode/workflows/*.js) - Workflow manager with session/chat creation and inference dispatch - Built-in catalog: deep-research, review-code, find-issues - Resumability cache: SHA-256 hash of agent spec, in-memory Map Phase 4: Background Subagents - background-task.ts service: spawn/poll/cancel lifecycle - spawn_subagent, subagent_status, subagent_result tools in ALL_TOOLS Phase 5: Multi-modal + Cache Shape - Multi-modal stub with type defs and hook point in payload.ts - CacheShapeBadge component in trace viewer (colored bar + %)
This commit is contained in:
132
apps/server/src/services/inference/compute-diff.ts
Normal file
132
apps/server/src/services/inference/compute-diff.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Compact unified-diff generator for write-tool results.
|
||||
*
|
||||
* Produces a minimal unified diff string (---/+++ header + +/- lines) from
|
||||
* old/new text pairs so the frontend can render an inline diff snippet
|
||||
* without pulling in a full diff library.
|
||||
*/
|
||||
|
||||
// Write-tool names that can produce file diffs.
|
||||
export const WRITE_TOOL_NAMES = new Set([
|
||||
'edit_file',
|
||||
'create_file',
|
||||
'delete_file',
|
||||
'apply_pending',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Compute a compact unified diff from old → new text.
|
||||
*
|
||||
* @param oldStr The original text (empty for creates)
|
||||
* @param newStr The replacement text (empty for deletes)
|
||||
* @param filePath Display path for the file header
|
||||
* @returns A unified-diff string, or empty string if old === new
|
||||
*/
|
||||
export function computeDiff(oldStr: string, newStr: string, filePath: string): string {
|
||||
if (oldStr === newStr) return '';
|
||||
|
||||
const oldLines = oldStr.split('\n');
|
||||
const newLines = newStr.split('\n');
|
||||
|
||||
// For empty old → new file (create), show all lines as additions
|
||||
if (oldStr.length === 0 && newStr.length > 0) {
|
||||
const header = `--- /dev/null\n+++ b/${filePath}\n`;
|
||||
const body = newLines.map((line) => `+${line}`).join('\n');
|
||||
return header + body;
|
||||
}
|
||||
|
||||
// For old → empty (delete), show all lines as removals
|
||||
if (newStr.length === 0 && oldStr.length > 0) {
|
||||
const header = `--- a/${filePath}\n+++ /dev/null\n`;
|
||||
const body = oldLines.map((line) => `-${line}`).join('\n');
|
||||
return header + body;
|
||||
}
|
||||
|
||||
// Simple line-by-line diff for edit: collect changed lines with context.
|
||||
// Uses a straightforward algorithm: find the first differing line and the
|
||||
// last differing line, then output the block with +/- markers.
|
||||
const header = `--- a/${filePath}\n+++ b/${filePath}\n`;
|
||||
|
||||
const maxLen = Math.max(oldLines.length, newLines.length);
|
||||
let firstDiff = -1;
|
||||
let lastDiff = -1;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = i < oldLines.length ? oldLines[i] : undefined;
|
||||
const b = i < newLines.length ? newLines[i] : undefined;
|
||||
if (a !== b) {
|
||||
if (firstDiff === -1) firstDiff = i;
|
||||
lastDiff = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstDiff === -1) return '';
|
||||
|
||||
// Add context lines around the changed block (up to 2 lines each side)
|
||||
const contextBefore = 2;
|
||||
const contextAfter = 2;
|
||||
const start = Math.max(0, firstDiff - contextBefore);
|
||||
const end = Math.min(maxLen - 1, lastDiff + contextAfter);
|
||||
|
||||
// Build the unified diff hunk
|
||||
const hunkLines: string[] = [];
|
||||
const hunkOldStart = start + 1; // 1-indexed
|
||||
const hunkNewStart = start + 1;
|
||||
const hunkOldLen = end - start + 1;
|
||||
const hunkNewLen = end - start + 1;
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const oldLine = i < oldLines.length ? oldLines[i] : undefined;
|
||||
const newLine = i < newLines.length ? newLines[i] : undefined;
|
||||
|
||||
if (oldLine === newLine) {
|
||||
hunkLines.push(` ${oldLine ?? ''}`);
|
||||
} else {
|
||||
if (oldLine !== undefined) {
|
||||
hunkLines.push(`-${oldLine}`);
|
||||
}
|
||||
if (newLine !== undefined) {
|
||||
hunkLines.push(`+${newLine}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hunkHeader = `@@ -${hunkOldStart},${hunkOldLen} +${hunkNewStart},${hunkNewLen} @@\n`;
|
||||
return header + hunkHeader + hunkLines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a tool name corresponds to a file-modifying write tool
|
||||
* that should produce a diff in its tool result.
|
||||
*/
|
||||
export function isWriteTool(name: string): boolean {
|
||||
return WRITE_TOOL_NAMES.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a diff string from tool call args for write tools.
|
||||
* Returns empty string if the tool doesn't produce diffs or args are missing.
|
||||
*/
|
||||
export function diffFromToolArgs(name: string, args: Record<string, unknown>, filePath?: string): string {
|
||||
switch (name) {
|
||||
case 'edit_file': {
|
||||
const oldStr = String(args.old_string ?? '');
|
||||
const newStr = String(args.new_string ?? '');
|
||||
const path = filePath ?? String(args.file_path ?? 'file');
|
||||
return computeDiff(oldStr, newStr, path);
|
||||
}
|
||||
case 'create_file': {
|
||||
const content = String(args.content ?? '');
|
||||
const path = filePath ?? String(args.file_path ?? 'file');
|
||||
return computeDiff('', content, path);
|
||||
}
|
||||
case 'delete_file':
|
||||
// No content available at queue time — actual content is read at apply time.
|
||||
return '';
|
||||
case 'apply_pending':
|
||||
// Meta-tool — individual changes produce their own diffs.
|
||||
return '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
56
apps/server/src/services/inference/multi-modal.ts
Normal file
56
apps/server/src/services/inference/multi-modal.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// vDeepSeek (stub): multi-modal (image) attachment support.
|
||||
//
|
||||
// When a message carries images, DeepSeek V4 models can process them
|
||||
// natively via the @ai-sdk/deepseek provider. This module provides the
|
||||
// helper types and functions to detect and convert image attachments.
|
||||
//
|
||||
// FULL INTEGRATION requires:
|
||||
// 1. Storing image data alongside messages (message_parts with kind='image'
|
||||
// or a dedicated attachments table with base64-encoded data).
|
||||
// 2. Extending OpenAiMessage.content from `string | null` to
|
||||
// `string | null | Array<{ type: 'text'; text: string } | { type: 'image'; image: string }>`
|
||||
// in apps/server/src/services/inference/payload.ts.
|
||||
// 3. Updating toModelMessages() in stream-phase-adapter.ts to emit AI SDK
|
||||
// content arrays with image parts for multimodal user messages.
|
||||
//
|
||||
// None of the above is done yet — this file is a type scaffold.
|
||||
|
||||
import type { Message } from '../../types/api.js';
|
||||
|
||||
/** Shape of a decoded image attachment ready for the AI SDK. */
|
||||
export interface ImageAttachment {
|
||||
/** Base64-encoded image data (no data URI prefix — raw bytes). */
|
||||
data: string;
|
||||
/** MIME type (e.g. 'image/png', 'image/jpeg', 'image/webp'). */
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user message has image content that can be forwarded to a
|
||||
* multimodal model. Currently a stub — always returns false until the
|
||||
* message-pipeline stores image attachments addressably.
|
||||
*/
|
||||
export function hasImageAttachments(_message: Message): boolean {
|
||||
// TODO(vDeepSeek): scan message_parts for kind='image' or inspect
|
||||
// message.content for inline data URIs (data:image/...).
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal image attachments to the format expected by the AI SDK
|
||||
* ModelMessage content array.
|
||||
*
|
||||
* The @ai-sdk/deepseek provider accepts images as:
|
||||
* { type: 'image'; image: 'data:image/png;base64,...' }
|
||||
*
|
||||
* @param attachments — List of decoded image attachments.
|
||||
* @returns AI SDK inline file parts suitable for ModelMessage.content.
|
||||
*/
|
||||
export function imageAttachmentsToParts(
|
||||
attachments: ImageAttachment[],
|
||||
): Array<{ type: 'image'; image: string }> {
|
||||
return attachments.map((a) => ({
|
||||
type: 'image' as const,
|
||||
image: `data:${a.mimeType};base64,${a.data}`,
|
||||
}));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
|
||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||
import { stripToolMarkup } from './tool-call-parser.js';
|
||||
import { repairToolInput } from './tool-input-repair.js';
|
||||
import { diffFromToolArgs, isWriteTool } from './compute-diff.js';
|
||||
import type { FailureKind } from './mistake-tracker.js';
|
||||
import { insertToolTrace, updateToolTrace } from '../tool-traces.js';
|
||||
import type {
|
||||
@@ -445,6 +446,16 @@ export async function executeToolPhase(
|
||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||
}
|
||||
// v2.8: compute a compact unified diff for successful write-tool results.
|
||||
// The diff is derived from tool call args (old_string/new_string for
|
||||
// edit_file, content for create_file) and included in the WS frame so
|
||||
// the frontend can render a DiffSnippet inline. Not persisted to message_parts
|
||||
// (the args alone are enough to reproduce it on reload if needed).
|
||||
const toolDiff =
|
||||
!tres.error && tres.outcome === 'success' && isWriteTool(tc.name)
|
||||
? diffFromToolArgs(tc.name, tc.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
output: tres.output,
|
||||
@@ -467,6 +478,7 @@ export async function executeToolPhase(
|
||||
output: tres.output,
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
...(toolDiff ? { diff: toolDiff } : {}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user