feat(cli): runTeam DAG dashboard (--dashboard)
Adds `oma run --dashboard` to write a static post-execution DAG HTML to `oma-dashboards/runTeam-<timestamp>.html`. - Pure `renderTeamRunDashboard(result: TeamRunResult)` in `src/dashboard/`, no FS/network I/O in the library - `TeamRunResult` gains `goal` + `tasks: TaskExecutionRecord[]` (with `TaskExecutionMetrics`) - `layoutTasks()` extracted as pure function with cycle detection - XSS mitigations: `application/json` payload, `</script>` escape, `textContent`-only node rendering - CLI awaits write before exit (no race with `process.exit`) Closes #4 Co-authored-by: Ibrahim Kazimov <74775400+ibrahimkzmv@users.noreply.github.com>
This commit is contained in:
parent
a33622bdf1
commit
647aeff8f4
|
|
@ -3,3 +3,4 @@ dist/
|
||||||
coverage/
|
coverage/
|
||||||
*.tgz
|
*.tgz
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
oma-dashboards/
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ Set the usual provider API keys in the environment (see [README](../README.md#qu
|
||||||
|
|
||||||
Runs **`OpenMultiAgent.runTeam(team, goal)`**: coordinator decomposition, task queue, optional synthesis.
|
Runs **`OpenMultiAgent.runTeam(team, goal)`**: coordinator decomposition, task queue, optional synthesis.
|
||||||
|
|
||||||
|
The **`oma` CLI** writes a static post-execution DAG dashboard HTML to `oma-dashboards/runTeam-<timestamp>.html` under the current working directory after each `runTeam` invocation (the library does not write files itself; if you want this outside the CLI, call `renderTeamRunDashboard()` in application code — see `src/dashboard/render-team-run-dashboard.ts`).
|
||||||
|
|
||||||
|
The dashboard page loads **Tailwind CSS** (Play CDN), **Google Fonts** (Space Grotesk, Inter, Material Symbols), and **Material Symbols** from the network at view time. Opening the HTML file requires an **online** environment unless you host or inline those assets yourself (a future improvement).
|
||||||
|
|
||||||
| Argument | Required | Description |
|
| Argument | Required | Description |
|
||||||
|----------|----------|-------------|
|
|----------|----------|-------------|
|
||||||
| `--goal` | Yes | Natural-language goal passed to the team run. |
|
| `--goal` | Yes | Natural-language goal passed to the team run. |
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@
|
||||||
* 3 — unexpected runtime error (including LLM errors)
|
* 3 — unexpected runtime error (including LLM errors)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { OpenMultiAgent } from '../orchestrator/orchestrator.js'
|
import { OpenMultiAgent } from '../orchestrator/orchestrator.js'
|
||||||
|
import { renderTeamRunDashboard } from '../dashboard/render-team-run-dashboard.js'
|
||||||
import type { SupportedProvider } from '../llm/adapter.js'
|
import type { SupportedProvider } from '../llm/adapter.js'
|
||||||
import type { AgentRunResult, CoordinatorConfig, OrchestratorConfig, TeamConfig, TeamRunResult } from '../types.js'
|
import type { AgentRunResult, CoordinatorConfig, OrchestratorConfig, TeamConfig, TeamRunResult } from '../types.js'
|
||||||
|
|
||||||
|
|
@ -224,6 +226,8 @@ export function serializeTeamRunResult(result: TeamRunResult, opts: CliJsonOptio
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
success: result.success,
|
||||||
|
goal: result.goal,
|
||||||
|
tasks: result.tasks,
|
||||||
totalTokenUsage: result.totalTokenUsage,
|
totalTokenUsage: result.totalTokenUsage,
|
||||||
agentResults,
|
agentResults,
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +250,7 @@ function help(): string {
|
||||||
'Flags:',
|
'Flags:',
|
||||||
' --pretty Pretty-print JSON to stdout',
|
' --pretty Pretty-print JSON to stdout',
|
||||||
' --include-messages Include full LLM message arrays in run output (large)',
|
' --include-messages Include full LLM message arrays in run output (large)',
|
||||||
|
' --dashboard Write team-run DAG HTML dashboard to oma-dashboards/',
|
||||||
'',
|
'',
|
||||||
'team.json may be a TeamConfig object, or { "team": TeamConfig, "orchestrator": { ... } }.',
|
'team.json may be a TeamConfig object, or { "team": TeamConfig, "orchestrator": { ... } }.',
|
||||||
'tasks.json: { "team": TeamConfig, "tasks": [ ... ], "orchestrator"?: { ... } }.',
|
'tasks.json: { "team": TeamConfig, "tasks": [ ... ], "orchestrator"?: { ... } }.',
|
||||||
|
|
@ -316,11 +321,21 @@ function mergeOrchestrator(base: OrchestratorConfig, ...partials: OrchestratorCo
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeRunTeamDashboardFile(html: string): Promise<string> {
|
||||||
|
const directory = join(process.cwd(), 'oma-dashboards')
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
const stamp = new Date().toISOString().replaceAll(':', '-').replace('.', '-')
|
||||||
|
const filePath = join(directory, `runTeam-${stamp}.html`)
|
||||||
|
await writeFile(filePath, html, 'utf8')
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<number> {
|
async function main(): Promise<number> {
|
||||||
const argv = parseArgs(process.argv)
|
const argv = parseArgs(process.argv)
|
||||||
const cmd = argv._[0]
|
const cmd = argv._[0]
|
||||||
const pretty = argv.flags.has('pretty')
|
const pretty = argv.flags.has('pretty')
|
||||||
const includeMessages = argv.flags.has('include-messages')
|
const includeMessages = argv.flags.has('include-messages')
|
||||||
|
const dashboard = argv.flags.has('dashboard')
|
||||||
|
|
||||||
if (cmd === undefined || cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
if (cmd === undefined || cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
||||||
process.stdout.write(`${help()}\n`)
|
process.stdout.write(`${help()}\n`)
|
||||||
|
|
@ -366,6 +381,16 @@ async function main(): Promise<number> {
|
||||||
coordinator = asCoordinatorPartial(readJson(coordPath), 'coordinator file')
|
coordinator = asCoordinatorPartial(readJson(coordPath), 'coordinator file')
|
||||||
}
|
}
|
||||||
const result = await orchestrator.runTeam(team, goal, coordinator ? { coordinator } : undefined)
|
const result = await orchestrator.runTeam(team, goal, coordinator ? { coordinator } : undefined)
|
||||||
|
if (dashboard) {
|
||||||
|
const html = renderTeamRunDashboard(result)
|
||||||
|
try {
|
||||||
|
await writeRunTeamDashboardFile(html)
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(
|
||||||
|
`oma: failed to write runTeam dashboard: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
await orchestrator.shutdown()
|
await orchestrator.shutdown()
|
||||||
const payload = { command: 'run' as const, ...serializeTeamRunResult(result, jsonOpts) }
|
const payload = { command: 'run' as const, ...serializeTeamRunResult(result, jsonOpts) }
|
||||||
printJson(payload, pretty)
|
printJson(payload, pretty)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Pure DAG layout for the team-run dashboard (mirrors the browser algorithm).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LayoutTaskInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly dependsOn?: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutTasksResult {
|
||||||
|
readonly positions: ReadonlyMap<string, { readonly x: number; readonly y: number }>
|
||||||
|
readonly width: number
|
||||||
|
readonly height: number
|
||||||
|
readonly nodeW: number
|
||||||
|
readonly nodeH: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns each task to a column by longest path from roots (topological level),
|
||||||
|
* then stacks rows within each column. Used by the dashboard canvas sizing.
|
||||||
|
*/
|
||||||
|
export function layoutTasks<T extends LayoutTaskInput>(taskList: readonly T[]): LayoutTasksResult {
|
||||||
|
const byId = new Map(taskList.map((task) => [task.id, task]))
|
||||||
|
const children = new Map<string, string[]>(taskList.map((task) => [task.id, []]))
|
||||||
|
const indegree = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const task of taskList) {
|
||||||
|
const deps = (task.dependsOn ?? []).filter((dep) => byId.has(dep))
|
||||||
|
indegree.set(task.id, deps.length)
|
||||||
|
for (const depId of deps) {
|
||||||
|
children.get(depId)!.push(task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels = new Map<string, number>()
|
||||||
|
const queue: string[] = []
|
||||||
|
let processed = 0
|
||||||
|
for (const task of taskList) {
|
||||||
|
if ((indegree.get(task.id) ?? 0) === 0) {
|
||||||
|
levels.set(task.id, 0)
|
||||||
|
queue.push(task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentId = queue.shift()!
|
||||||
|
processed += 1
|
||||||
|
const baseLevel = levels.get(currentId) ?? 0
|
||||||
|
for (const childId of children.get(currentId) ?? []) {
|
||||||
|
const nextLevel = Math.max(levels.get(childId) ?? 0, baseLevel + 1)
|
||||||
|
levels.set(childId, nextLevel)
|
||||||
|
indegree.set(childId, (indegree.get(childId) ?? 1) - 1)
|
||||||
|
if ((indegree.get(childId) ?? 0) === 0) {
|
||||||
|
queue.push(childId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processed !== taskList.length) {
|
||||||
|
throw new Error('Task dependency graph contains a cycle')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of taskList) {
|
||||||
|
if (!levels.has(task.id)) levels.set(task.id, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = new Map<number, T[]>()
|
||||||
|
for (const task of taskList) {
|
||||||
|
const level = levels.get(task.id) ?? 0
|
||||||
|
if (!cols.has(level)) cols.set(level, [])
|
||||||
|
cols.get(level)!.push(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedLevels = Array.from(cols.keys()).sort((a, b) => a - b)
|
||||||
|
const nodeW = 256
|
||||||
|
const nodeH = 142
|
||||||
|
const colGap = 96
|
||||||
|
const rowGap = 72
|
||||||
|
const padX = 120
|
||||||
|
const padY = 100
|
||||||
|
const positions = new Map<string, { x: number; y: number }>()
|
||||||
|
let maxRows = 1
|
||||||
|
for (const level of sortedLevels) maxRows = Math.max(maxRows, cols.get(level)!.length)
|
||||||
|
|
||||||
|
for (const level of sortedLevels) {
|
||||||
|
const colTasks = cols.get(level)!
|
||||||
|
colTasks.forEach((task, idx) => {
|
||||||
|
positions.set(task.id, {
|
||||||
|
x: padX + level * (nodeW + colGap),
|
||||||
|
y: padY + idx * (nodeH + rowGap),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.max(1600, padX * 2 + sortedLevels.length * (nodeW + colGap))
|
||||||
|
const height = Math.max(700, padY * 2 + maxRows * (nodeH + rowGap))
|
||||||
|
return { positions, width, height, nodeW, nodeH }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,460 @@
|
||||||
|
/**
|
||||||
|
* Pure HTML renderer for the post-run team task DAG dashboard (no filesystem or network I/O).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamRunResult } from '../types.js'
|
||||||
|
import { layoutTasks } from './layout-tasks.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape serialized JSON so it can be embedded in HTML without closing a {@code <script>} tag.
|
||||||
|
* The HTML tokenizer ends a script on {@code </script>} even for {@code type="application/json"}.
|
||||||
|
*/
|
||||||
|
export function escapeJsonForHtmlScript(json: string): string {
|
||||||
|
return json.replace(/<\/script/gi, '<\\/script')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTeamRunDashboard(result: TeamRunResult): string {
|
||||||
|
const generatedAt = new Date().toISOString()
|
||||||
|
const tasks = result.tasks ?? []
|
||||||
|
const layout = layoutTasks(tasks)
|
||||||
|
const serializedPositions = Object.fromEntries(layout.positions)
|
||||||
|
const payload = {
|
||||||
|
generatedAt,
|
||||||
|
goal: result.goal ?? '',
|
||||||
|
tasks,
|
||||||
|
layout: {
|
||||||
|
positions: serializedPositions,
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
nodeW: layout.nodeW,
|
||||||
|
nodeH: layout.nodeH,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const dataJson = escapeJsonForHtmlScript(JSON.stringify(payload))
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html class="dark" lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||||
|
<title>Open Multi Agent</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||||
|
rel="stylesheet" />
|
||||||
|
<script id="tailwind-config">
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
"colors": {
|
||||||
|
"inverse-surface": "#faf8ff",
|
||||||
|
"secondary-dim": "#ecb200",
|
||||||
|
"on-primary": "#005762",
|
||||||
|
"on-tertiary-fixed-variant": "#006827",
|
||||||
|
"primary-fixed-dim": "#00d4ec",
|
||||||
|
"tertiary-container": "#5cfd80",
|
||||||
|
"secondary": "#fdc003",
|
||||||
|
"primary-dim": "#00d4ec",
|
||||||
|
"surface-container": "#0f1930",
|
||||||
|
"on-secondary": "#553e00",
|
||||||
|
"surface": "#060e20",
|
||||||
|
"on-surface": "#dee5ff",
|
||||||
|
"surface-container-highest": "#192540",
|
||||||
|
"on-secondary-fixed-variant": "#674c00",
|
||||||
|
"on-tertiary-container": "#005d22",
|
||||||
|
"secondary-fixed-dim": "#f7ba00",
|
||||||
|
"surface-variant": "#192540",
|
||||||
|
"surface-container-low": "#091328",
|
||||||
|
"secondary-container": "#785900",
|
||||||
|
"tertiary-fixed-dim": "#4bee74",
|
||||||
|
"on-primary-fixed-variant": "#005762",
|
||||||
|
"primary-container": "#00e3fd",
|
||||||
|
"surface-dim": "#060e20",
|
||||||
|
"error-container": "#9f0519",
|
||||||
|
"on-error-container": "#ffa8a3",
|
||||||
|
"primary-fixed": "#00e3fd",
|
||||||
|
"tertiary-dim": "#4bee74",
|
||||||
|
"surface-container-high": "#141f38",
|
||||||
|
"background": "#060e20",
|
||||||
|
"surface-bright": "#1f2b49",
|
||||||
|
"error-dim": "#d7383b",
|
||||||
|
"on-primary-container": "#004d57",
|
||||||
|
"outline": "#6d758c",
|
||||||
|
"error": "#ff716c",
|
||||||
|
"on-secondary-container": "#fff6ec",
|
||||||
|
"on-primary-fixed": "#003840",
|
||||||
|
"inverse-on-surface": "#4d556b",
|
||||||
|
"secondary-fixed": "#ffca4d",
|
||||||
|
"tertiary-fixed": "#5cfd80",
|
||||||
|
"on-tertiary-fixed": "#004819",
|
||||||
|
"surface-tint": "#81ecff",
|
||||||
|
"tertiary": "#b8ffbb",
|
||||||
|
"outline-variant": "#40485d",
|
||||||
|
"on-error": "#490006",
|
||||||
|
"on-surface-variant": "#a3aac4",
|
||||||
|
"surface-container-lowest": "#000000",
|
||||||
|
"on-tertiary": "#006727",
|
||||||
|
"primary": "#81ecff",
|
||||||
|
"on-secondary-fixed": "#443100",
|
||||||
|
"inverse-primary": "#006976",
|
||||||
|
"on-background": "#dee5ff"
|
||||||
|
},
|
||||||
|
"borderRadius": {
|
||||||
|
"DEFAULT": "0px",
|
||||||
|
"lg": "0px",
|
||||||
|
"xl": "0px",
|
||||||
|
"full": "9999px"
|
||||||
|
},
|
||||||
|
"fontFamily": {
|
||||||
|
"headline": ["Space Grotesk"],
|
||||||
|
"body": ["Inter"],
|
||||||
|
"label": ["Space Grotesk"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-pattern {
|
||||||
|
background-image: radial-gradient(circle, #40485d 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-active-glow {
|
||||||
|
box-shadow: 0 0 15px rgba(129, 236, 255, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface text-on-surface font-body selection:bg-primary selection:text-on-primary">
|
||||||
|
<main class="p-8 min-h-[calc(100vh-64px)] grid-pattern relative overflow-hidden flex flex-col lg:flex-row gap-6">
|
||||||
|
<div id="viewport" class="flex-1 relative min-h-[600px] overflow-hidden cursor-grab">
|
||||||
|
<div id="canvas" class="absolute inset-0 origin-top-left">
|
||||||
|
<svg id="edgesLayer" class="absolute inset-0 w-full h-full pointer-events-none" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||||
|
<div id="nodesLayer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside id="detailsPanel" class="hidden w-full lg:w-[400px] bg-surface-container-high p-6 flex flex-col gap-8 border-l border-outline-variant/10">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-headline font-black text-lg tracking-widest mb-6 text-primary flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined" data-icon="info">info</span>
|
||||||
|
NODE_DETAILS
|
||||||
|
</h2>
|
||||||
|
<button id="closePanel" class="absolute top-4 right-4 text-on-surface-variant hover:text-primary">
|
||||||
|
<span class="material-symbols-outlined">close</span>
|
||||||
|
</button>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Goal</label>
|
||||||
|
<p id="goalText" class="text-xs bg-surface-container p-3 border-b border-outline-variant/20"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Assigned Agent</label>
|
||||||
|
<div class="flex items-center gap-4 bg-surface-container p-3">
|
||||||
|
<div>
|
||||||
|
<p id="selectedAssignee" class="text-sm font-bold text-on-surface">-</p>
|
||||||
|
<p id="selectedState" class="text-[10px] font-mono text-secondary">ACTIVE STATE: -</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Execution Start</label>
|
||||||
|
<p id="selectedStart" class="text-xs font-mono bg-surface-container p-2 border-b border-outline-variant/20">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Execution End</label>
|
||||||
|
<p id="selectedEnd" class="text-xs font-mono bg-surface-container p-2 border-b border-outline-variant/20 text-on-surface-variant">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Token Breakdown</label>
|
||||||
|
<div class="space-y-2 bg-surface-container p-4">
|
||||||
|
<div class="flex justify-between text-xs font-mono">
|
||||||
|
<span class="text-on-surface-variant">PROMPT:</span>
|
||||||
|
<span id="selectedPromptTokens" class="text-on-surface">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs font-mono">
|
||||||
|
<span class="text-on-surface-variant">COMPLETION:</span>
|
||||||
|
<span id="selectedCompletionTokens" class="text-on-surface text-secondary">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-1 bg-surface-variant mt-2">
|
||||||
|
<div id="selectedTokenRatio" class="bg-primary h-full w-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[10px] font-headline uppercase tracking-widest text-on-surface-variant">Tool Calls</label>
|
||||||
|
<p id="selectedToolCalls" class="text-xs font-mono bg-surface-container p-2 border-b border-outline-variant/20">0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex flex-col min-h-[200px]">
|
||||||
|
<h2 class="font-headline font-black text-[10px] tracking-widest mb-4 text-on-surface-variant">LIVE_AGENT_OUTPUT</h2>
|
||||||
|
<div id="liveOutput" class="bg-surface-container-lowest flex-1 p-3 font-mono text-[10px] leading-relaxed overflow-y-auto space-y-1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
<div class="fixed left-0 top-0 w-1 h-screen bg-gradient-to-b from-primary via-secondary to-tertiary z-[60] opacity-30"></div>
|
||||||
|
<script type="application/json" id="oma-data">${dataJson}</script>
|
||||||
|
<script>
|
||||||
|
const dataEl = document.getElementById("oma-data");
|
||||||
|
const payload = JSON.parse(dataEl.textContent);
|
||||||
|
const panel = document.getElementById("detailsPanel");
|
||||||
|
const closeBtn = document.getElementById("closePanel");
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
|
const viewport = document.getElementById("viewport");
|
||||||
|
const edgesLayer = document.getElementById("edgesLayer");
|
||||||
|
const nodesLayer = document.getElementById("nodesLayer");
|
||||||
|
const goalText = document.getElementById("goalText");
|
||||||
|
const liveOutput = document.getElementById("liveOutput");
|
||||||
|
const selectedAssignee = document.getElementById("selectedAssignee");
|
||||||
|
const selectedState = document.getElementById("selectedState");
|
||||||
|
const selectedStart = document.getElementById("selectedStart");
|
||||||
|
const selectedToolCalls = document.getElementById("selectedToolCalls");
|
||||||
|
const selectedEnd = document.getElementById("selectedEnd");
|
||||||
|
const selectedPromptTokens = document.getElementById("selectedPromptTokens");
|
||||||
|
const selectedCompletionTokens = document.getElementById("selectedCompletionTokens");
|
||||||
|
const selectedTokenRatio = document.getElementById("selectedTokenRatio");
|
||||||
|
const svgNs = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
let scale = 1;
|
||||||
|
let translate = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
let isDragging = false;
|
||||||
|
let last = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
function updateTransform() {
|
||||||
|
canvas.style.transform = \`
|
||||||
|
translate(\${translate.x}px, \${translate.y}px)
|
||||||
|
scale(\${scale})
|
||||||
|
\`;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewport.addEventListener("wheel", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const zoomIntensity = 0.0015;
|
||||||
|
const delta = -e.deltaY * zoomIntensity;
|
||||||
|
const newScale = Math.min(Math.max(0.4, scale + delta), 2.5);
|
||||||
|
|
||||||
|
const rect = viewport.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
const dx = mouseX - translate.x;
|
||||||
|
const dy = mouseY - translate.y;
|
||||||
|
|
||||||
|
translate.x -= dx * (newScale / scale - 1);
|
||||||
|
translate.y -= dy * (newScale / scale - 1);
|
||||||
|
scale = newScale;
|
||||||
|
updateTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
viewport.addEventListener("mousedown", (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
last = { x: e.clientX, y: e.clientY };
|
||||||
|
viewport.classList.add("cursor-grabbing");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", (e) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const dx = e.clientX - last.x;
|
||||||
|
const dy = e.clientY - last.y;
|
||||||
|
translate.x += dx;
|
||||||
|
translate.y += dy;
|
||||||
|
last = { x: e.clientX, y: e.clientY };
|
||||||
|
updateTransform();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("mouseup", () => {
|
||||||
|
isDragging = false;
|
||||||
|
viewport.classList.remove("cursor-grabbing");
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
|
||||||
|
closeBtn.addEventListener("click", () => {
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const isClickInsidePanel = panel.contains(e.target);
|
||||||
|
const isNode = e.target.closest(".node");
|
||||||
|
|
||||||
|
if (!isClickInsidePanel && !isNode) {
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasks = Array.isArray(payload.tasks) ? payload.tasks : [];
|
||||||
|
goalText.textContent = payload.goal ?? "";
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
completed: { border: "border-tertiary", icon: "check_circle", iconColor: "text-tertiary", container: "bg-surface-container-lowest node-active-glow", statusColor: "text-on-surface-variant", chip: "STABLE" },
|
||||||
|
failed: { border: "border-error", icon: "error", iconColor: "text-error", container: "bg-surface-container-lowest", statusColor: "text-error", chip: "FAILED" },
|
||||||
|
blocked: { border: "border-outline", icon: "lock", iconColor: "text-outline", container: "bg-surface-container-low opacity-60 grayscale", statusColor: "text-on-surface-variant", chip: "BLOCKED" },
|
||||||
|
skipped: { border: "border-outline", icon: "skip_next", iconColor: "text-outline", container: "bg-surface-container-low opacity-60", statusColor: "text-on-surface-variant", chip: "SKIPPED" },
|
||||||
|
in_progress: { border: "border-secondary", icon: "sync", iconColor: "text-secondary", container: "bg-surface-container-low node-active-glow border border-outline-variant/20 shadow-[0_0_20px_rgba(253,192,3,0.1)]", statusColor: "text-secondary", chip: "ACTIVE_STREAM", spin: true },
|
||||||
|
pending: { border: "border-outline", icon: "hourglass_empty", iconColor: "text-outline", container: "bg-surface-container-low opacity-60 grayscale", statusColor: "text-on-surface-variant", chip: "WAITING" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function durationText(task) {
|
||||||
|
const ms = task?.metrics?.durationMs ?? 0;
|
||||||
|
const seconds = Math.max(0, ms / 1000).toFixed(1);
|
||||||
|
return task.status === "completed" ? "DONE (" + seconds + "s)" : task.status.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLiveOutput(taskList) {
|
||||||
|
liveOutput.innerHTML = "";
|
||||||
|
const finished = taskList.every((task) => ["completed", "failed", "skipped", "blocked"].includes(task.status));
|
||||||
|
const header = document.createElement("p");
|
||||||
|
header.className = "text-tertiary";
|
||||||
|
header.textContent = finished ? "[SYSTEM] Task graph execution finished." : "[SYSTEM] Task graph execution in progress.";
|
||||||
|
liveOutput.appendChild(header);
|
||||||
|
|
||||||
|
taskList.forEach((task) => {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = task.status === "completed" ? "text-on-surface-variant" : task.status === "failed" ? "text-error" : "text-on-surface-variant";
|
||||||
|
p.textContent = "[" + (task.assignee || "UNASSIGNED").toUpperCase() + "] " + task.title + " -> " + task.status.toUpperCase();
|
||||||
|
liveOutput.appendChild(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(task) {
|
||||||
|
const metrics = task?.metrics ?? {};
|
||||||
|
const statusLabel = (statusStyles[task.status] || statusStyles.pending).chip;
|
||||||
|
const usage = metrics.tokenUsage ?? { input_tokens: 0, output_tokens: 0 };
|
||||||
|
const inTokens = usage.input_tokens ?? 0;
|
||||||
|
const outTokens = usage.output_tokens ?? 0;
|
||||||
|
const total = inTokens + outTokens;
|
||||||
|
const ratio = total > 0 ? Math.round((inTokens / total) * 100) : 0;
|
||||||
|
|
||||||
|
selectedAssignee.textContent = task?.assignee || "UNASSIGNED";
|
||||||
|
|
||||||
|
selectedState.textContent = "STATE: " + statusLabel;
|
||||||
|
selectedStart.textContent = metrics.startMs ? new Date(metrics.startMs).toISOString() : "-";
|
||||||
|
selectedEnd.textContent = metrics.endMs ? new Date(metrics.endMs).toISOString() : "-";
|
||||||
|
|
||||||
|
selectedToolCalls.textContent = (metrics.toolCalls ?? []).length.toString();
|
||||||
|
|
||||||
|
selectedPromptTokens.textContent = inTokens.toLocaleString();
|
||||||
|
selectedCompletionTokens.textContent = outTokens.toLocaleString();
|
||||||
|
selectedTokenRatio.style.width = ratio + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEdgePath(x1, y1, x2, y2) {
|
||||||
|
return "M " + x1 + " " + y1 + " C " + (x1 + 42) + " " + y1 + ", " + (x2 - 42) + " " + y2 + ", " + x2 + " " + y2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDag(taskList) {
|
||||||
|
const rawLayout = payload.layout ?? {};
|
||||||
|
const positions = new Map(Object.entries(rawLayout.positions ?? {}));
|
||||||
|
const width = Number(rawLayout.width ?? 1600);
|
||||||
|
const height = Number(rawLayout.height ?? 700);
|
||||||
|
const nodeW = Number(rawLayout.nodeW ?? 256);
|
||||||
|
const nodeH = Number(rawLayout.nodeH ?? 142);
|
||||||
|
canvas.style.width = width + "px";
|
||||||
|
canvas.style.height = height + "px";
|
||||||
|
|
||||||
|
edgesLayer.setAttribute("viewBox", "0 0 " + width + " " + height);
|
||||||
|
edgesLayer.innerHTML = "";
|
||||||
|
const defs = document.createElementNS(svgNs, "defs");
|
||||||
|
const marker = document.createElementNS(svgNs, "marker");
|
||||||
|
marker.setAttribute("id", "arrow");
|
||||||
|
marker.setAttribute("markerWidth", "8");
|
||||||
|
marker.setAttribute("markerHeight", "8");
|
||||||
|
marker.setAttribute("refX", "7");
|
||||||
|
marker.setAttribute("refY", "4");
|
||||||
|
marker.setAttribute("orient", "auto");
|
||||||
|
const markerPath = document.createElementNS(svgNs, "path");
|
||||||
|
markerPath.setAttribute("d", "M0,0 L8,4 L0,8 z");
|
||||||
|
markerPath.setAttribute("fill", "#40485d");
|
||||||
|
marker.appendChild(markerPath);
|
||||||
|
defs.appendChild(marker);
|
||||||
|
edgesLayer.appendChild(defs);
|
||||||
|
|
||||||
|
taskList.forEach((task) => {
|
||||||
|
const to = positions.get(task.id);
|
||||||
|
(task.dependsOn || []).forEach((depId) => {
|
||||||
|
const from = positions.get(depId);
|
||||||
|
if (!from || !to) return;
|
||||||
|
const edge = document.createElementNS(svgNs, "path");
|
||||||
|
edge.setAttribute("d", makeEdgePath(from.x + nodeW, from.y + nodeH / 2, to.x, to.y + nodeH / 2));
|
||||||
|
edge.setAttribute("fill", "none");
|
||||||
|
edge.setAttribute("stroke", "#40485d");
|
||||||
|
edge.setAttribute("stroke-width", "2");
|
||||||
|
edge.setAttribute("marker-end", "url(#arrow)");
|
||||||
|
edgesLayer.appendChild(edge);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesLayer.innerHTML = "";
|
||||||
|
taskList.forEach((task, idx) => {
|
||||||
|
const pos = positions.get(task.id);
|
||||||
|
const status = statusStyles[task.status] || statusStyles.pending;
|
||||||
|
const nodeId = "#NODE_" + String(idx + 1).padStart(3, "0");
|
||||||
|
const chips = [task.assignee ? task.assignee.toUpperCase() : "UNASSIGNED", status.chip];
|
||||||
|
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = "node absolute w-64 border-l-2 p-4 cursor-pointer " + status.border + " " + status.container;
|
||||||
|
node.style.left = pos.x + "px";
|
||||||
|
node.style.top = pos.y + "px";
|
||||||
|
|
||||||
|
const rowTop = document.createElement("div");
|
||||||
|
rowTop.className = "flex justify-between items-start mb-4";
|
||||||
|
const nodeIdSpan = document.createElement("span");
|
||||||
|
nodeIdSpan.className = "text-[10px] font-mono " + status.iconColor;
|
||||||
|
nodeIdSpan.textContent = nodeId;
|
||||||
|
const iconSpan = document.createElement("span");
|
||||||
|
iconSpan.className = "material-symbols-outlined " + status.iconColor + " text-lg " + (status.spin ? "animate-spin" : "");
|
||||||
|
iconSpan.textContent = status.icon;
|
||||||
|
iconSpan.setAttribute("data-icon", status.icon);
|
||||||
|
rowTop.appendChild(nodeIdSpan);
|
||||||
|
rowTop.appendChild(iconSpan);
|
||||||
|
|
||||||
|
const titleEl = document.createElement("h3");
|
||||||
|
titleEl.className = "font-headline font-bold text-sm tracking-tight mb-1";
|
||||||
|
titleEl.textContent = task.title;
|
||||||
|
|
||||||
|
const statusLine = document.createElement("p");
|
||||||
|
statusLine.className = "text-xs " + status.statusColor + " mb-4";
|
||||||
|
statusLine.textContent = "STATUS: " + durationText(task);
|
||||||
|
|
||||||
|
const chipRow = document.createElement("div");
|
||||||
|
chipRow.className = "flex gap-2";
|
||||||
|
chips.forEach((chip) => {
|
||||||
|
const chipEl = document.createElement("span");
|
||||||
|
chipEl.className = "px-2 py-0.5 bg-surface-variant text-[9px] font-mono text-on-surface-variant";
|
||||||
|
chipEl.textContent = chip;
|
||||||
|
chipRow.appendChild(chipEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
node.appendChild(rowTop);
|
||||||
|
node.appendChild(titleEl);
|
||||||
|
node.appendChild(statusLine);
|
||||||
|
node.appendChild(chipRow);
|
||||||
|
|
||||||
|
node.addEventListener("click", () => {
|
||||||
|
renderDetails(task);
|
||||||
|
panel.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
nodesLayer.appendChild(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLiveOutput(taskList);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDag(tasks);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,8 @@ export { OpenMultiAgent, executeWithRetry, computeRetryDelay } from './orchestra
|
||||||
export { Scheduler } from './orchestrator/scheduler.js'
|
export { Scheduler } from './orchestrator/scheduler.js'
|
||||||
export type { SchedulingStrategy } from './orchestrator/scheduler.js'
|
export type { SchedulingStrategy } from './orchestrator/scheduler.js'
|
||||||
|
|
||||||
|
export { renderTeamRunDashboard } from './dashboard/render-team-run-dashboard.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Agent layer
|
// Agent layer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -164,6 +166,10 @@ export type {
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamRunResult,
|
TeamRunResult,
|
||||||
|
|
||||||
|
// Dashboard (static HTML)
|
||||||
|
TaskExecutionMetrics,
|
||||||
|
TaskExecutionRecord,
|
||||||
|
|
||||||
// Task
|
// Task
|
||||||
Task,
|
Task,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ import type {
|
||||||
OrchestratorConfig,
|
OrchestratorConfig,
|
||||||
OrchestratorEvent,
|
OrchestratorEvent,
|
||||||
Task,
|
Task,
|
||||||
|
TaskExecutionMetrics,
|
||||||
|
TaskExecutionRecord,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamInfo,
|
TeamInfo,
|
||||||
|
|
@ -414,6 +416,7 @@ interface RunContext {
|
||||||
readonly maxTokenBudget?: number
|
readonly maxTokenBudget?: number
|
||||||
budgetExceededTriggered: boolean
|
budgetExceededTriggered: boolean
|
||||||
budgetExceededReason?: string
|
budgetExceededReason?: string
|
||||||
|
readonly taskMetrics: Map<string, TaskExecutionMetrics>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -616,7 +619,7 @@ async function executeQueue(
|
||||||
team: buildTaskAgentTeamInfo(ctx, task.id, traceBase, 0, [assignee]),
|
team: buildTaskAgentTeamInfo(ctx, task.id, traceBase, 0, [assignee]),
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskStartMs = config.onTrace ? Date.now() : 0
|
const taskStartMs = Date.now()
|
||||||
let retryCount = 0
|
let retryCount = 0
|
||||||
|
|
||||||
const result = await executeWithRetry(
|
const result = await executeWithRetry(
|
||||||
|
|
@ -633,9 +636,10 @@ async function executeQueue(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const taskEndMs = Date.now()
|
||||||
|
|
||||||
// Emit task trace
|
// Emit task trace
|
||||||
if (config.onTrace) {
|
if (config.onTrace) {
|
||||||
const taskEndMs = Date.now()
|
|
||||||
emitTrace(config.onTrace, {
|
emitTrace(config.onTrace, {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
runId: ctx.runId ?? '',
|
runId: ctx.runId ?? '',
|
||||||
|
|
@ -651,6 +655,14 @@ async function executeQueue(
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.agentResults.set(`${assignee}:${task.id}`, result)
|
ctx.agentResults.set(`${assignee}:${task.id}`, result)
|
||||||
|
|
||||||
|
ctx.taskMetrics.set(task.id, {
|
||||||
|
startMs: taskStartMs,
|
||||||
|
endMs: taskEndMs,
|
||||||
|
durationMs: Math.max(0, taskEndMs - taskStartMs),
|
||||||
|
tokenUsage: result.tokenUsage,
|
||||||
|
toolCalls: result.toolCalls,
|
||||||
|
})
|
||||||
ctx.cumulativeUsage = addUsage(ctx.cumulativeUsage, result.tokenUsage)
|
ctx.cumulativeUsage = addUsage(ctx.cumulativeUsage, result.tokenUsage)
|
||||||
const totalTokens = ctx.cumulativeUsage.input_tokens + ctx.cumulativeUsage.output_tokens
|
const totalTokens = ctx.cumulativeUsage.input_tokens + ctx.cumulativeUsage.output_tokens
|
||||||
if (
|
if (
|
||||||
|
|
@ -1008,7 +1020,9 @@ export class OpenMultiAgent {
|
||||||
? { ...(traceFields ?? {}), ...(abortFields ?? {}) }
|
? { ...(traceFields ?? {}), ...(abortFields ?? {}) }
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
const scStartMs = Date.now()
|
||||||
const result = await agent.run(goal, runOptions)
|
const result = await agent.run(goal, runOptions)
|
||||||
|
const scEndMs = Date.now()
|
||||||
|
|
||||||
if (result.budgetExceeded) {
|
if (result.budgetExceeded) {
|
||||||
this.config.onProgress?.({
|
this.config.onProgress?.({
|
||||||
|
|
@ -1030,7 +1044,23 @@ export class OpenMultiAgent {
|
||||||
|
|
||||||
const agentResults = new Map<string, AgentRunResult>()
|
const agentResults = new Map<string, AgentRunResult>()
|
||||||
agentResults.set(bestAgent.name, result)
|
agentResults.set(bestAgent.name, result)
|
||||||
return this.buildTeamRunResult(agentResults)
|
|
||||||
|
|
||||||
|
const tasks: readonly TaskExecutionRecord[] = [{
|
||||||
|
id: 'short-circuit',
|
||||||
|
title: `Short-circuit: ${bestAgent.name}`,
|
||||||
|
assignee: bestAgent.name,
|
||||||
|
status: result.success ? 'completed' : 'failed',
|
||||||
|
dependsOn: [],
|
||||||
|
metrics: {
|
||||||
|
startMs: scStartMs,
|
||||||
|
endMs: scEndMs,
|
||||||
|
durationMs: Math.max(0, scEndMs - scStartMs),
|
||||||
|
tokenUsage: result.tokenUsage,
|
||||||
|
toolCalls: result.toolCalls,
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
return this.buildTeamRunResult(agentResults, goal, tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -1085,7 +1115,7 @@ export class OpenMultiAgent {
|
||||||
maxTokenBudget,
|
maxTokenBudget,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
return this.buildTeamRunResult(agentResults)
|
return this.buildTeamRunResult(agentResults, goal, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -1095,6 +1125,7 @@ export class OpenMultiAgent {
|
||||||
|
|
||||||
const queue = new TaskQueue()
|
const queue = new TaskQueue()
|
||||||
const scheduler = new Scheduler('dependency-first')
|
const scheduler = new Scheduler('dependency-first')
|
||||||
|
const taskMetrics = new Map<string, TaskExecutionMetrics>()
|
||||||
|
|
||||||
if (taskSpecs && taskSpecs.length > 0) {
|
if (taskSpecs && taskSpecs.length > 0) {
|
||||||
// Map title-based dependsOn references to real task IDs so we can
|
// Map title-based dependsOn references to real task IDs so we can
|
||||||
|
|
@ -1134,10 +1165,19 @@ export class OpenMultiAgent {
|
||||||
maxTokenBudget,
|
maxTokenBudget,
|
||||||
budgetExceededTriggered: false,
|
budgetExceededTriggered: false,
|
||||||
budgetExceededReason: undefined,
|
budgetExceededReason: undefined,
|
||||||
|
taskMetrics,
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeQueue(queue, ctx)
|
await executeQueue(queue, ctx)
|
||||||
cumulativeUsage = ctx.cumulativeUsage
|
cumulativeUsage = ctx.cumulativeUsage
|
||||||
|
const taskRecords: readonly TaskExecutionRecord[] = queue.list().map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
assignee: task.assignee,
|
||||||
|
status: task.status,
|
||||||
|
dependsOn: task.dependsOn ?? [],
|
||||||
|
metrics: taskMetrics.get(task.id),
|
||||||
|
}))
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Step 5: Coordinator synthesises final result
|
// Step 5: Coordinator synthesises final result
|
||||||
|
|
@ -1146,7 +1186,7 @@ export class OpenMultiAgent {
|
||||||
maxTokenBudget !== undefined
|
maxTokenBudget !== undefined
|
||||||
&& cumulativeUsage.input_tokens + cumulativeUsage.output_tokens > maxTokenBudget
|
&& cumulativeUsage.input_tokens + cumulativeUsage.output_tokens > maxTokenBudget
|
||||||
) {
|
) {
|
||||||
return this.buildTeamRunResult(agentResults)
|
return this.buildTeamRunResult(agentResults, goal, taskRecords)
|
||||||
}
|
}
|
||||||
const synthesisPrompt = await this.buildSynthesisPrompt(goal, queue.list(), team)
|
const synthesisPrompt = await this.buildSynthesisPrompt(goal, queue.list(), team)
|
||||||
const synthTraceOptions: Partial<RunOptions> | undefined = this.config.onTrace
|
const synthTraceOptions: Partial<RunOptions> | undefined = this.config.onTrace
|
||||||
|
|
@ -1180,7 +1220,7 @@ export class OpenMultiAgent {
|
||||||
// Only actual user tasks (non-coordinator keys) are counted in
|
// Only actual user tasks (non-coordinator keys) are counted in
|
||||||
// buildTeamRunResult, so we do not increment completedTaskCount here.
|
// buildTeamRunResult, so we do not increment completedTaskCount here.
|
||||||
|
|
||||||
return this.buildTeamRunResult(agentResults)
|
return this.buildTeamRunResult(agentResults, goal, taskRecords)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -1246,11 +1286,21 @@ export class OpenMultiAgent {
|
||||||
maxTokenBudget: this.config.maxTokenBudget,
|
maxTokenBudget: this.config.maxTokenBudget,
|
||||||
budgetExceededTriggered: false,
|
budgetExceededTriggered: false,
|
||||||
budgetExceededReason: undefined,
|
budgetExceededReason: undefined,
|
||||||
|
taskMetrics: new Map<string, TaskExecutionMetrics>(),
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeQueue(queue, ctx)
|
await executeQueue(queue, ctx)
|
||||||
|
|
||||||
return this.buildTeamRunResult(agentResults)
|
const taskRecords: readonly TaskExecutionRecord[] = queue.list().map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
assignee: task.assignee,
|
||||||
|
status: task.status,
|
||||||
|
dependsOn: task.dependsOn ?? [],
|
||||||
|
metrics: ctx.taskMetrics.get(task.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return this.buildTeamRunResult(agentResults, undefined, taskRecords)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -1529,6 +1579,8 @@ export class OpenMultiAgent {
|
||||||
*/
|
*/
|
||||||
private buildTeamRunResult(
|
private buildTeamRunResult(
|
||||||
agentResults: Map<string, AgentRunResult>,
|
agentResults: Map<string, AgentRunResult>,
|
||||||
|
goal?: string,
|
||||||
|
tasks?: readonly TaskExecutionRecord[],
|
||||||
): TeamRunResult {
|
): TeamRunResult {
|
||||||
let totalUsage: TokenUsage = ZERO_USAGE
|
let totalUsage: TokenUsage = ZERO_USAGE
|
||||||
let overallSuccess = true
|
let overallSuccess = true
|
||||||
|
|
@ -1566,6 +1618,8 @@ export class OpenMultiAgent {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: overallSuccess,
|
success: overallSuccess,
|
||||||
|
goal,
|
||||||
|
tasks,
|
||||||
agentResults: collapsed,
|
agentResults: collapsed,
|
||||||
totalTokenUsage: totalUsage,
|
totalTokenUsage: totalUsage,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
src/types.ts
24
src/types.ts
|
|
@ -441,6 +441,8 @@ export interface TeamConfig {
|
||||||
/** Aggregated result for a full team run. */
|
/** Aggregated result for a full team run. */
|
||||||
export interface TeamRunResult {
|
export interface TeamRunResult {
|
||||||
readonly success: boolean
|
readonly success: boolean
|
||||||
|
readonly goal?: string
|
||||||
|
readonly tasks?: readonly TaskExecutionRecord[]
|
||||||
/** Keyed by agent name. */
|
/** Keyed by agent name. */
|
||||||
readonly agentResults: Map<string, AgentRunResult>
|
readonly agentResults: Map<string, AgentRunResult>
|
||||||
readonly totalTokenUsage: TokenUsage
|
readonly totalTokenUsage: TokenUsage
|
||||||
|
|
@ -453,6 +455,28 @@ export interface TeamRunResult {
|
||||||
/** Valid states for a {@link Task}. */
|
/** Valid states for a {@link Task}. */
|
||||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked' | 'skipped'
|
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked' | 'skipped'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metrics shown in the team-run dashboard detail panel for a single task.
|
||||||
|
* Mirrors execution data collected during orchestration.
|
||||||
|
*/
|
||||||
|
export interface TaskExecutionMetrics {
|
||||||
|
readonly startMs: number
|
||||||
|
readonly endMs: number
|
||||||
|
readonly durationMs: number
|
||||||
|
readonly tokenUsage: TokenUsage
|
||||||
|
readonly toolCalls: AgentRunResult['toolCalls']
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializable task snapshot embedded in the static HTML dashboard. */
|
||||||
|
export interface TaskExecutionRecord {
|
||||||
|
readonly id: string
|
||||||
|
readonly title: string
|
||||||
|
readonly assignee?: string
|
||||||
|
readonly status: TaskStatus
|
||||||
|
readonly dependsOn: readonly string[]
|
||||||
|
readonly metrics?: TaskExecutionMetrics
|
||||||
|
}
|
||||||
|
|
||||||
/** A discrete unit of work tracked by the orchestrator. */
|
/** A discrete unit of work tracked by the orchestrator. */
|
||||||
export interface Task {
|
export interface Task {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { layoutTasks } from '../src/dashboard/layout-tasks.js'
|
||||||
|
|
||||||
|
describe('layoutTasks', () => {
|
||||||
|
it('assigns increasing columns along a dependency chain (topological levels)', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: 'a', dependsOn: [] as const },
|
||||||
|
{ id: 'b', dependsOn: ['a'] as const },
|
||||||
|
{ id: 'c', dependsOn: ['b'] as const },
|
||||||
|
]
|
||||||
|
const { positions } = layoutTasks(tasks)
|
||||||
|
expect(positions.get('a')!.x).toBeLessThan(positions.get('b')!.x)
|
||||||
|
expect(positions.get('b')!.x).toBeLessThan(positions.get('c')!.x)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places a merge node after all of its dependencies (diamond)', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: 'root', dependsOn: [] as const },
|
||||||
|
{ id: 'left', dependsOn: ['root'] as const },
|
||||||
|
{ id: 'right', dependsOn: ['root'] as const },
|
||||||
|
{ id: 'merge', dependsOn: ['left', 'right'] as const },
|
||||||
|
]
|
||||||
|
const { positions } = layoutTasks(tasks)
|
||||||
|
const mx = positions.get('merge')!.x
|
||||||
|
expect(mx).toBeGreaterThan(positions.get('left')!.x)
|
||||||
|
expect(mx).toBeGreaterThan(positions.get('right')!.x)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('orders independent roots in the same column with distinct rows', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: 'a', dependsOn: [] as const },
|
||||||
|
{ id: 'b', dependsOn: [] as const },
|
||||||
|
]
|
||||||
|
const { positions } = layoutTasks(tasks)
|
||||||
|
expect(positions.get('a')!.x).toBe(positions.get('b')!.x)
|
||||||
|
expect(positions.get('a')!.y).not.toBe(positions.get('b')!.y)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when task dependencies contain a cycle', () => {
|
||||||
|
const tasks = [
|
||||||
|
{ id: 'a', dependsOn: ['b'] as const },
|
||||||
|
{ id: 'b', dependsOn: ['a'] as const },
|
||||||
|
]
|
||||||
|
expect(() => layoutTasks(tasks)).toThrow('Task dependency graph contains a cycle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderTeamRunDashboard } from '../src/dashboard/render-team-run-dashboard.js'
|
||||||
|
|
||||||
|
describe('renderTeamRunDashboard', () => {
|
||||||
|
it('does not embed unescaped script terminators in the JSON payload and keeps XSS payloads out of HTML markup', () => {
|
||||||
|
const malicious = '"</script><img src=x onerror=alert(1)>"'
|
||||||
|
const html = renderTeamRunDashboard({
|
||||||
|
success: true,
|
||||||
|
goal: 'safe-goal',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
title: malicious,
|
||||||
|
status: 'pending',
|
||||||
|
dependsOn: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
agentResults: new Map(),
|
||||||
|
totalTokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const dataOpen = 'id="oma-data">'
|
||||||
|
const start = html.indexOf(dataOpen)
|
||||||
|
expect(start).toBeGreaterThan(-1)
|
||||||
|
const contentStart = start + dataOpen.length
|
||||||
|
const end = html.indexOf('</script>', contentStart)
|
||||||
|
expect(end).toBeGreaterThan(contentStart)
|
||||||
|
const jsonSlice = html.slice(contentStart, end)
|
||||||
|
expect(jsonSlice.toLowerCase()).not.toContain('</script')
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonSlice) as { tasks: { title: string }[] }
|
||||||
|
expect(parsed.tasks[0]!.title).toBe(malicious)
|
||||||
|
|
||||||
|
const beforeData = html.slice(0, start)
|
||||||
|
expect(beforeData).not.toContain(malicious)
|
||||||
|
expect(beforeData.toLowerCase()).not.toMatch(/\sonerror\s*=/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps task description text in JSON payload', () => {
|
||||||
|
const description = 'danger: </script><svg onload=alert(1)>'
|
||||||
|
const html = renderTeamRunDashboard({
|
||||||
|
success: true,
|
||||||
|
goal: 'safe-goal',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
title: 'task',
|
||||||
|
description,
|
||||||
|
status: 'pending',
|
||||||
|
dependsOn: [],
|
||||||
|
} as { id: string; title: string; description: string; status: 'pending'; dependsOn: string[] },
|
||||||
|
],
|
||||||
|
agentResults: new Map(),
|
||||||
|
totalTokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const start = html.indexOf('id="oma-data">')
|
||||||
|
const contentStart = start + 'id="oma-data">'.length
|
||||||
|
const end = html.indexOf('</script>', contentStart)
|
||||||
|
const parsed = JSON.parse(html.slice(contentStart, end)) as {
|
||||||
|
tasks: Array<{ description?: string }>
|
||||||
|
}
|
||||||
|
expect(parsed.tasks[0]!.description).toBe(description)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps task result text in JSON payload', () => {
|
||||||
|
const result = 'final output </script><img src=x onerror=alert(1)>'
|
||||||
|
const html = renderTeamRunDashboard({
|
||||||
|
success: true,
|
||||||
|
goal: 'safe-goal',
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
title: 'task',
|
||||||
|
result,
|
||||||
|
status: 'completed',
|
||||||
|
dependsOn: [],
|
||||||
|
} as { id: string; title: string; result: string; status: 'completed'; dependsOn: string[] },
|
||||||
|
],
|
||||||
|
agentResults: new Map(),
|
||||||
|
totalTokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const start = html.indexOf('id="oma-data">')
|
||||||
|
const contentStart = start + 'id="oma-data">'.length
|
||||||
|
const end = html.indexOf('</script>', contentStart)
|
||||||
|
const parsed = JSON.parse(html.slice(contentStart, end)) as {
|
||||||
|
tasks: Array<{ result?: string }>
|
||||||
|
}
|
||||||
|
expect(parsed.tasks[0]!.result).toBe(result)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue