pi-agent-fleet 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -8
- package/examples/json-number-pipeline.json +35 -0
- package/package.json +2 -2
- package/src/canvas-client.tsx +184 -66
- package/src/canvas-layout.ts +83 -0
- package/src/canvas.ts +178 -86
- package/src/command.ts +56 -42
- package/src/contracts.ts +76 -20
- package/src/controller.ts +72 -7
- package/src/dag.ts +32 -7
- package/src/edits.ts +7 -8
- package/src/fleet-recovery.ts +83 -0
- package/src/fleet-store.ts +11 -2
- package/src/insert.ts +2 -8
- package/src/model-resolution.ts +27 -2
- package/src/preferences.ts +10 -1
- package/src/prompts.ts +21 -0
- package/src/report.ts +28 -1
- package/src/scheduler.ts +51 -5
- package/src/state.ts +17 -17
- package/src/tools.ts +70 -35
- package/src/types.ts +8 -0
- package/src/worktree.ts +6 -0
package/README.md
CHANGED
|
@@ -42,7 +42,13 @@ Ask your pi session (the LLM drives the tools):
|
|
|
42
42
|
> combiner (depends on both) writes output/sum.md with the total.
|
|
43
43
|
> Each declares its output as a markdown contract.
|
|
44
44
|
|
|
45
|
-
The agent calls `fleet_design` (if you describe it in prose) or `fleet_plan` (if you already have JSON), you confirm the preview, then `fleet_launch` runs it.
|
|
45
|
+
The agent calls `fleet_design` (if you describe it in prose) or `fleet_plan` (if you already have JSON), you confirm the preview, then `fleet_launch` runs it. Plan and launch responses include a fleet canvas link by default; the in-chat widget is hidden until you run `/fleet viz`. Read the report at `.fleet/<name>-<ts>/report.md`.
|
|
46
|
+
|
|
47
|
+
JSON pipeline variant — a numeric handoff chain where workers pass typed JSON, verified by schemas at contract check:
|
|
48
|
+
|
|
49
|
+
> Plan and launch the fleet defined in `examples/json-number-pipeline.json`.
|
|
50
|
+
|
|
51
|
+
One writer emits `{"values":[3,5,8]}`, two parallel consumers add and subtract, a synthesizer combines the results — each output declared as a `json` contract with a `schema` naming its required and numeric keys.
|
|
46
52
|
|
|
47
53
|
## Writing a fleet
|
|
48
54
|
|
|
@@ -78,7 +84,7 @@ The agent calls `fleet_design` (if you describe it in prose) or `fleet_plan` (if
|
|
|
78
84
|
|
|
79
85
|
- `config.model` / `config.effort` — fleet-wide defaults; per-worker `model` and `effort` override.
|
|
80
86
|
- `effort` maps to pi thinking levels: `off | minimal | low | medium | high | xhigh | max`.
|
|
81
|
-
- `config.warn_cost_usd` — soft cost guardrail surfaced in the
|
|
87
|
+
- `config.warn_cost_usd` — soft cost guardrail surfaced in the canvas and report.
|
|
82
88
|
- `iterate: false` — run the node once at iteration 1 and carry its outputs forward.
|
|
83
89
|
- `worktree: true` — run the node in a dedicated git worktree.
|
|
84
90
|
- Worker types `research`, `code-run`, `reviewer`, `write`, `read-only` each get a tailored tool set.
|
|
@@ -112,9 +118,16 @@ Every worker declares `outputs[]` with kinds, verified in code at worker exit be
|
|
|
112
118
|
| `markdown` | exists, non-empty, starts with `#` |
|
|
113
119
|
| `file-exists` | exists, non-empty (repo-relative paths = code edits) |
|
|
114
120
|
| `verdict` | `verdict: lgtm\|iterate\|escalate` line + non-empty body |
|
|
115
|
-
| `json` | parses as JSON |
|
|
121
|
+
| `json` | parses as JSON; optional `schema` checks (below) |
|
|
116
122
|
| `yaml` | parses as YAML |
|
|
117
123
|
|
|
124
|
+
JSON outputs may declare a `schema` with `required_keys` (keys that must exist) and `number_keys` (keys that must be numbers or arrays of numbers). Schemas are only allowed on `kind: "json"` outputs, are injected into the worker's prompt, and are enforced at contract check. When a `schema` is present, the JSON must be a top-level object (arrays and scalars fail):
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{ "path": "output/sum.json", "kind": "json", "required": true,
|
|
128
|
+
"schema": { "required_keys": ["operation", "result"], "number_keys": ["result"] } }
|
|
129
|
+
```
|
|
130
|
+
|
|
118
131
|
Failed required contract → `contract_failed`, dependents blocked, orchestrator notified. No silent passes.
|
|
119
132
|
|
|
120
133
|
## Tools & commands
|
|
@@ -123,8 +136,10 @@ Failed required contract → `contract_failed`, dependents blocked, orchestrator
|
|
|
123
136
|
|---|---|
|
|
124
137
|
| `fleet_design` | draft a fleet DAG from plain-language requirements (planner agent → validated JSON + preview) |
|
|
125
138
|
| `fleet_plan` | validate + preview a fleet definition (no launch) |
|
|
139
|
+
| `fleet_models` | list available model refs (provider/id) from the live registry — call before `fleet_plan` if you don't know exact model IDs |
|
|
126
140
|
| `fleet_launch` | launch the planned fleet after user confirmation; `skip_confirm` for unattended runs |
|
|
127
|
-
| `fleet_status` | live DAG status and
|
|
141
|
+
| `fleet_status` | live DAG status and text summary |
|
|
142
|
+
| `fleet_continue` | resume a failed/killed fleet from current state without restarting completed nodes |
|
|
128
143
|
| `fleet_pause` / `fleet_resume` | pause/resume loop fleets at the next iteration boundary |
|
|
129
144
|
| `fleet_kill` | kill all, or kill a single node by worker id |
|
|
130
145
|
| `fleet_relaunch` | re-run a failed/killed node and its blocked downstream; optional model override |
|
|
@@ -133,7 +148,7 @@ Failed required contract → `contract_failed`, dependents blocked, orchestrator
|
|
|
133
148
|
| `fleet_report` | regenerate the fleet markdown report |
|
|
134
149
|
| `fleet_canvas` | open a browser canvas; `?demo=1` shows synthetic data for UI iteration |
|
|
135
150
|
|
|
136
|
-
`/fleet viz | status | clear | pause | resume | kill all|<node_id> | relaunch <id> [model] | add <json> | edit <node_id>|config ... | configure [show|set k v] | canvas [open|url|stop]`
|
|
151
|
+
`/fleet viz | status | clear | pause | resume | continue | kill all|<node_id> | relaunch <id> [model] | add <json> | edit <node_id>|config ... | configure [show|set k v] | canvas [open|url|stop]`
|
|
137
152
|
|
|
138
153
|
## Runtime mutation
|
|
139
154
|
|
|
@@ -156,7 +171,7 @@ These are merged into `fleet_plan` results. Manage them with `/fleet configure s
|
|
|
156
171
|
|
|
157
172
|
## Records
|
|
158
173
|
|
|
159
|
-
Everything lands in `.fleet/<name>-<ts>/` (git-ignored): `state.json` (single source of truth, atomic writes), per-worker `prompt.md` + `session.jsonl` + outputs, per-iteration archives, and a machine-written `report.md` with per-worker turns/tokens/cost, contract results, verdict history, and git diff stats. Completed nodes keep their stats visible in the
|
|
174
|
+
Everything lands in `.fleet/<name>-<ts>/` (git-ignored): `state.json` (single source of truth, atomic writes), per-worker `prompt.md` + `session.jsonl` + outputs, per-iteration archives, and a machine-written `report.md` with per-worker turns/tokens/cost, contract results, verdict history, and git diff stats. Completed nodes keep their stats visible in the canvas and report after the fleet ends.
|
|
160
175
|
|
|
161
176
|
## Model selection and effort
|
|
162
177
|
|
|
@@ -172,11 +187,11 @@ Model refs are validated at plan and launch time so bad names fail fast. There i
|
|
|
172
187
|
|
|
173
188
|
```bash
|
|
174
189
|
npm install
|
|
175
|
-
npm test #
|
|
190
|
+
npm test # 274 tests, zero-API (fake session factory)
|
|
176
191
|
npm run typecheck
|
|
177
192
|
```
|
|
178
193
|
|
|
179
|
-
Design docs
|
|
194
|
+
Design docs are archived locally under `.archive/docs/superpowers/` (not tracked); experiment history in `docs/experiments/`.
|
|
180
195
|
|
|
181
196
|
## License
|
|
182
197
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fleet_name": "json-number-pipeline",
|
|
3
|
+
"type": "dag",
|
|
4
|
+
"config": { "max_concurrent": 2 },
|
|
5
|
+
"workers": [
|
|
6
|
+
{
|
|
7
|
+
"id": "write-numbers",
|
|
8
|
+
"type": "write",
|
|
9
|
+
"task": "Write output/numbers.json containing exactly {\"values\":[3,5,8]}. No markdown. No commentary.",
|
|
10
|
+
"depends_on": [],
|
|
11
|
+
"outputs": [{ "path": "output/numbers.json", "kind": "json", "required": true, "schema": { "required_keys": ["values"], "number_keys": ["values"] } }]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "add-numbers",
|
|
15
|
+
"type": "write",
|
|
16
|
+
"task": "Read output/numbers.json from write-numbers. Write output/sum.json containing {\"operation\":\"add\",\"result\":16}.",
|
|
17
|
+
"depends_on": ["write-numbers"],
|
|
18
|
+
"outputs": [{ "path": "output/sum.json", "kind": "json", "required": true, "schema": { "required_keys": ["operation", "result"], "number_keys": ["result"] } }]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "subtract-numbers",
|
|
22
|
+
"type": "write",
|
|
23
|
+
"task": "Read output/numbers.json from write-numbers. Write output/difference.json containing {\"operation\":\"subtract\",\"result\":-10} using first value minus the rest.",
|
|
24
|
+
"depends_on": ["write-numbers"],
|
|
25
|
+
"outputs": [{ "path": "output/difference.json", "kind": "json", "required": true, "schema": { "required_keys": ["operation", "result"], "number_keys": ["result"] } }]
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "synthesize",
|
|
29
|
+
"type": "write",
|
|
30
|
+
"task": "Read output/sum.json and output/difference.json. Write output/final.json containing {\"sum\":16,\"difference\":-10,\"combined\":6} where combined = sum + difference.",
|
|
31
|
+
"depends_on": ["add-numbers", "subtract-numbers"],
|
|
32
|
+
"outputs": [{ "path": "output/final.json", "kind": "json", "required": true, "schema": { "required_keys": ["sum", "difference", "combined"], "number_keys": ["sum", "difference", "combined"] } }]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-agent-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "DAG-of-agents fleets for pi — parallel workers with contracts, reviewer-gated iteration loops, and live progress",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"extensions": [
|
|
25
25
|
"./src/index.ts"
|
|
26
26
|
],
|
|
27
|
-
"image": "https://raw.githubusercontent.com/sagarsrc/pi-agent-fleet/v0.
|
|
27
|
+
"image": "https://raw.githubusercontent.com/sagarsrc/pi-agent-fleet/v0.5.0/assets/canvas.png"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"author": "Sagar Sarkale",
|
package/src/canvas-client.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
useReactFlow,
|
|
17
17
|
} from "@xyflow/react";
|
|
18
18
|
import type { Edge, Node, NodeProps } from "@xyflow/react";
|
|
19
|
+
import { computePositions, excerptText, NODE_W } from "./canvas-layout.js";
|
|
19
20
|
|
|
20
21
|
/* ---------- payload types (mirror canvas.ts) ---------- */
|
|
21
22
|
interface CanvasNodeView {
|
|
@@ -54,10 +55,28 @@ interface CanvasPayload {
|
|
|
54
55
|
}
|
|
55
56
|
interface FleetInfo { name: string; status: string }
|
|
56
57
|
interface SessionEntry { role: string; text: string }
|
|
57
|
-
interface
|
|
58
|
+
interface ActionView {
|
|
59
|
+
type: "tool_call" | "tool_result" | "model_change" | "thinking_level_change" | "complete";
|
|
60
|
+
name?: string;
|
|
61
|
+
toolName?: string;
|
|
62
|
+
arguments?: Record<string, unknown>;
|
|
63
|
+
provider?: string;
|
|
64
|
+
modelId?: string;
|
|
65
|
+
thinkingLevel?: string;
|
|
66
|
+
stopReason?: string;
|
|
67
|
+
isError?: boolean;
|
|
68
|
+
timestamp?: string;
|
|
69
|
+
}
|
|
70
|
+
type TimelineEvent =
|
|
71
|
+
| { type: "message"; role: string; text: string; timestamp?: string }
|
|
72
|
+
| { type: "tool_call"; name: string; arguments?: Record<string, unknown>; timestamp?: string }
|
|
73
|
+
| { type: "tool_result"; toolName?: string; isError?: boolean; text?: string; timestamp?: string }
|
|
74
|
+
| { type: "model_change"; provider: string; modelId: string; timestamp?: string }
|
|
75
|
+
| { type: "thinking_level_change"; thinkingLevel: string; timestamp?: string }
|
|
76
|
+
| { type: "complete"; stopReason: string; timestamp?: string };
|
|
77
|
+
interface SessionResp { entries: SessionEntry[]; actions: ActionView[]; events: TimelineEvent[]; task?: string }
|
|
58
78
|
|
|
59
79
|
/* ---------- helpers ---------- */
|
|
60
|
-
const NODE_W = 284;
|
|
61
80
|
function statusClass(s: string): string {
|
|
62
81
|
return "st-" + s.replace(/\s+/g, "_");
|
|
63
82
|
}
|
|
@@ -78,47 +97,6 @@ function j<T>(u: string): Promise<T> {
|
|
|
78
97
|
});
|
|
79
98
|
}
|
|
80
99
|
|
|
81
|
-
/* topo-layer layout, ported from the legacy canvas */
|
|
82
|
-
function topoLayers(nodes: CanvasNodeView[], edges: Array<{ from: string; to: string }>): string[][] {
|
|
83
|
-
const ids = nodes.map((n) => n.id);
|
|
84
|
-
const indeg: Record<string, number> = {};
|
|
85
|
-
const rev: Record<string, string[]> = {};
|
|
86
|
-
ids.forEach((i) => { indeg[i] = 0; rev[i] = []; });
|
|
87
|
-
edges.forEach((e) => { if (e.from in indeg) { indeg[e.to]++; rev[e.from].push(e.to); } });
|
|
88
|
-
const layers: string[][] = [];
|
|
89
|
-
let cur = ids.filter((i) => indeg[i] === 0);
|
|
90
|
-
const seen: Record<string, boolean> = {};
|
|
91
|
-
while (cur.length) {
|
|
92
|
-
layers.push(cur);
|
|
93
|
-
cur.forEach((i) => { seen[i] = true; });
|
|
94
|
-
const next: string[] = [];
|
|
95
|
-
cur.forEach((i) => rev[i].forEach((m) => { if (--indeg[m] === 0) next.push(m); }));
|
|
96
|
-
cur = next;
|
|
97
|
-
}
|
|
98
|
-
ids.forEach((i) => { if (!seen[i]) { layers.push([i]); seen[i] = true; } });
|
|
99
|
-
return layers;
|
|
100
|
-
}
|
|
101
|
-
function median(arr: number[]): number {
|
|
102
|
-
const s = arr.slice().sort((a, b) => a - b);
|
|
103
|
-
const m = Math.floor(s.length / 2);
|
|
104
|
-
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
105
|
-
}
|
|
106
|
-
function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
|
|
107
|
-
for (let li = 1; li < layers.length; li++) {
|
|
108
|
-
const prevPos: Record<string, number> = {};
|
|
109
|
-
layers[li - 1].forEach((id, i) => { prevPos[id] = i; });
|
|
110
|
-
const key = (id: string) => median(edges.filter((e) => e.to === id).map((e) => prevPos[e.from] ?? 0));
|
|
111
|
-
layers[li].sort((a, b) => key(a) - key(b));
|
|
112
|
-
}
|
|
113
|
-
return layers;
|
|
114
|
-
}
|
|
115
|
-
function computePositions(p: CanvasPayload): Record<string, { x: number; y: number }> {
|
|
116
|
-
const layers = reduceCrossings(topoLayers(p.nodes, p.edges), p.edges);
|
|
117
|
-
const pos: Record<string, { x: number; y: number }> = {};
|
|
118
|
-
layers.forEach((layer, li) => layer.forEach((id, ni) => { pos[id] = { x: li * 360, y: ni * 170 }; }));
|
|
119
|
-
return pos;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
100
|
/* ---------- custom node ---------- */
|
|
123
101
|
type FleetNodeData = {
|
|
124
102
|
view: CanvasNodeView;
|
|
@@ -160,8 +138,8 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
160
138
|
onClick={activate}
|
|
161
139
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }}
|
|
162
140
|
>
|
|
163
|
-
<Handle type="target" position={Position.
|
|
164
|
-
<Handle type="target" position={Position.
|
|
141
|
+
<Handle type="target" position={Position.Top} />
|
|
142
|
+
<Handle type="target" position={Position.Top} id="loopIn" />
|
|
165
143
|
<div className="card-body">
|
|
166
144
|
<div className="node-header">
|
|
167
145
|
<span className={"node-dot" + (running ? " pulse" : "")} style={{ background: minimapColor(n.status) }} aria-hidden="true" />
|
|
@@ -191,10 +169,10 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
191
169
|
))}
|
|
192
170
|
</div>
|
|
193
171
|
)}
|
|
194
|
-
{n.status_note && <div className="note">{n.status_note}</div>}
|
|
195
|
-
{failReason && <div className="fail-reason">{failReason}</div>}
|
|
172
|
+
{n.status_note && <div className="note nodrag">{n.status_note}</div>}
|
|
173
|
+
{failReason && <div className="fail-reason nodrag">{failReason}</div>}
|
|
196
174
|
</div>
|
|
197
|
-
<Handle type="source" position={Position.
|
|
175
|
+
<Handle type="source" position={Position.Bottom} />
|
|
198
176
|
<Handle type="source" position={Position.Bottom} id="loop" />
|
|
199
177
|
</div>
|
|
200
178
|
);
|
|
@@ -202,6 +180,142 @@ function FleetNode({ data }: NodeProps<Node<FleetNodeData>>) {
|
|
|
202
180
|
const nodeTypes = { fleet: FleetNode };
|
|
203
181
|
|
|
204
182
|
/* ---------- side panel ---------- */
|
|
183
|
+
function CollapsiblePrompt({ title, text }: { title: string; text: string }) {
|
|
184
|
+
const [open, setOpen] = useState(false);
|
|
185
|
+
if (!text) return null;
|
|
186
|
+
return (
|
|
187
|
+
<div className={"collapsible" + (open ? "" : " collapsed")}>
|
|
188
|
+
<button className="collapsible-head" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
|
|
189
|
+
<span className="chevron">{open ? "-" : "+"}</span>
|
|
190
|
+
<span>{title}</span>
|
|
191
|
+
</button>
|
|
192
|
+
<div className="collapsible-body">{text}</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function formatActionDetail(a: { arguments?: Record<string, unknown> }): string {
|
|
198
|
+
const args = a.arguments || {};
|
|
199
|
+
if (typeof args.path === "string") return args.path;
|
|
200
|
+
if (typeof args.command === "string") return args.command;
|
|
201
|
+
if (Array.isArray(args.queries)) return String(args.queries[0]);
|
|
202
|
+
const keys = Object.keys(args);
|
|
203
|
+
if (keys[0]) return `${keys[0]}: ${JSON.stringify(args[keys[0]]).slice(0, 40)}`;
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function TimelineItem({ event }: { event: TimelineEvent }) {
|
|
208
|
+
const [open, setOpen] = useState(event.isError ? true : false);
|
|
209
|
+
const [expanded, setExpanded] = useState(false);
|
|
210
|
+
const ts = event.timestamp ? new Date(event.timestamp).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
|
|
211
|
+
if (event.type === "message") {
|
|
212
|
+
const { excerpt, truncated } = excerptText(event.text, 240);
|
|
213
|
+
const text = truncated && !expanded ? excerpt : event.text;
|
|
214
|
+
return (
|
|
215
|
+
<div className={"timeline-msg" + (event.role === "assistant" ? " assistant" : event.role === "user" ? " user" : "")}>
|
|
216
|
+
<div className="timeline-meta">
|
|
217
|
+
<span className="role">{event.role}</span>
|
|
218
|
+
{ts && <span className="ts">{ts}</span>}
|
|
219
|
+
</div>
|
|
220
|
+
<div className="timeline-text">{text}</div>
|
|
221
|
+
{truncated && (
|
|
222
|
+
<button className="timeline-more" onClick={() => setExpanded((v) => !v)}>
|
|
223
|
+
{expanded ? "show less" : "show more"}
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (event.type === "tool_call") {
|
|
230
|
+
const detail = formatActionDetail(event);
|
|
231
|
+
const hasArgs = !!event.arguments && Object.keys(event.arguments).length > 0;
|
|
232
|
+
return (
|
|
233
|
+
<div className={"timeline-action" + (open ? " open" : "")}>
|
|
234
|
+
<div className="timeline-row">
|
|
235
|
+
<button className="activity-toggle" onClick={() => setOpen((v) => !v)} aria-expanded={open} title={open ? "collapse" : "expand"}>{open ? "-" : "+"}</button>
|
|
236
|
+
<span className="action-icon">call</span>
|
|
237
|
+
<span className="action-name">{event.name}</span>
|
|
238
|
+
{detail && <span className="action-detail" title={detail}>{detail}</span>}
|
|
239
|
+
{ts && <span className="ts">{ts}</span>}
|
|
240
|
+
</div>
|
|
241
|
+
{open && hasArgs && (
|
|
242
|
+
<div className="activity-body">
|
|
243
|
+
<pre>{JSON.stringify(event.arguments, null, 2)}</pre>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (event.type === "tool_result") {
|
|
250
|
+
const hasText = !!event.text && event.text.length > 0;
|
|
251
|
+
const expanded = open;
|
|
252
|
+
return (
|
|
253
|
+
<div className={"timeline-action" + (event.isError ? " action-error" : "") + (expanded ? " open" : "")}>
|
|
254
|
+
<div className="timeline-row">
|
|
255
|
+
<button className="activity-toggle" onClick={() => setOpen((v) => !v)} aria-expanded={expanded} title={expanded ? "collapse" : "expand"}>{expanded ? "-" : "+"}</button>
|
|
256
|
+
<span className={"action-icon" + (event.isError ? " action-error" : "")}>{event.isError ? "err" : "ok"}</span>
|
|
257
|
+
<span className="action-name">{event.toolName || "result"}</span>
|
|
258
|
+
{event.text && <span className={"action-detail" + (event.isError ? " action-error" : "")} title={event.text}>{event.isError ? "Error: " : ""}{event.text}</span>}
|
|
259
|
+
{ts && <span className="ts">{ts}</span>}
|
|
260
|
+
</div>
|
|
261
|
+
{expanded && hasText && (
|
|
262
|
+
<div className="activity-body">
|
|
263
|
+
{event.isError ? <strong>Error: </strong> : null}
|
|
264
|
+
{event.text}
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (event.type === "model_change") {
|
|
271
|
+
return (
|
|
272
|
+
<div className="timeline-action">
|
|
273
|
+
<div className="timeline-row">
|
|
274
|
+
<span className="action-icon">mdl</span>
|
|
275
|
+
<span className="action-name">model</span>
|
|
276
|
+
<span className="action-detail">{event.provider}/{event.modelId}</span>
|
|
277
|
+
{ts && <span className="ts">{ts}</span>}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (event.type === "thinking_level_change") {
|
|
283
|
+
return (
|
|
284
|
+
<div className="timeline-action">
|
|
285
|
+
<div className="timeline-row">
|
|
286
|
+
<span className="action-icon">think</span>
|
|
287
|
+
<span className="action-name">thinking</span>
|
|
288
|
+
<span className="action-detail">{event.thinkingLevel}</span>
|
|
289
|
+
{ts && <span className="ts">{ts}</span>}
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (event.type === "complete") {
|
|
295
|
+
return (
|
|
296
|
+
<div className="timeline-action">
|
|
297
|
+
<div className="timeline-row">
|
|
298
|
+
<span className="action-icon">done</span>
|
|
299
|
+
<span className="action-name">done</span>
|
|
300
|
+
{event.stopReason !== "complete" && event.stopReason !== "stop" && <span className="action-detail">{event.stopReason}</span>}
|
|
301
|
+
{ts && <span className="ts">{ts}</span>}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function Timeline({ events }: { events: TimelineEvent[] }) {
|
|
310
|
+
const visible = events.filter((e) => e.type !== "model_change" && e.type !== "thinking_level_change");
|
|
311
|
+
if (!visible.length) return <div className="timeline-empty">No session data yet.</div>;
|
|
312
|
+
return (
|
|
313
|
+
<div className="timeline" aria-label="Agent session timeline">
|
|
314
|
+
{visible.map((e, i) => <TimelineItem event={e} key={e.type + (e.timestamp || "") + "-" + i} />)}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
205
319
|
function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | null; demo: boolean; selected: string | null; task: string | null; onClose: () => void }) {
|
|
206
320
|
const [resp, setResp] = useState<SessionResp | null>(null);
|
|
207
321
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
@@ -214,11 +328,12 @@ function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | n
|
|
|
214
328
|
};
|
|
215
329
|
|
|
216
330
|
useEffect(() => {
|
|
217
|
-
if (!selected
|
|
331
|
+
if (!selected) { setResp(null); return; }
|
|
218
332
|
let alive = true;
|
|
219
333
|
const load = () => {
|
|
220
334
|
const q = fleet ? "&fleet=" + encodeURIComponent(fleet) : "";
|
|
221
|
-
|
|
335
|
+
const demoQ = demo ? "&demo=1" : "";
|
|
336
|
+
j<SessionResp>("/api/session/" + selected + "?tail=30" + q + demoQ).then((r) => alive && setResp(r)).catch(() => {});
|
|
222
337
|
};
|
|
223
338
|
load();
|
|
224
339
|
const t = setInterval(load, 2000);
|
|
@@ -246,26 +361,29 @@ function SidePanel({ fleet, demo, selected, task, onClose }: { fleet: string | n
|
|
|
246
361
|
if (nearBottom) el.scrollTop = el.scrollHeight;
|
|
247
362
|
}, [resp]);
|
|
248
363
|
|
|
364
|
+
const latestModel = resp?.events?.slice().reverse().find((e): e is TimelineEvent & { type: "model_change" } => e.type === "model_change");
|
|
365
|
+
const latestThinking = resp?.events?.slice().reverse().find((e): e is TimelineEvent & { type: "thinking_level_change" } => e.type === "thinking_level_change");
|
|
249
366
|
if (!selected) return null;
|
|
250
367
|
return (
|
|
251
|
-
<div id="side" className="open"
|
|
368
|
+
<div id="side" className="open" role="complementary" aria-label={`${selected} session`}>
|
|
252
369
|
<div className="side-head">
|
|
253
|
-
<span className="meta"
|
|
370
|
+
<span className="meta"><span className="side-hash">#</span> <strong className="side-id">{selected}</strong> — session</span>
|
|
254
371
|
<button className="icon-btn" onClick={closeAndRestore} aria-label="Close session panel" title="Close (Esc)">×</button>
|
|
372
|
+
{(!!latestModel || !!latestThinking) && (
|
|
373
|
+
<div className="side-meta">
|
|
374
|
+
{latestModel && <span className="side-meta-chip">{latestModel.provider}/{latestModel.modelId}</span>}
|
|
375
|
+
{latestThinking && <span className="side-meta-chip">thinking: {latestThinking.thinkingLevel}</span>}
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
<div className="side-body" ref={boxRef} tabIndex={-1}>
|
|
380
|
+
{resp === null ? (
|
|
381
|
+
<div className="timeline-loading"><span className="spinner" aria-hidden="true" /> Loading session…</div>
|
|
382
|
+
) : (
|
|
383
|
+
<Timeline events={resp.events ?? []} />
|
|
384
|
+
)}
|
|
385
|
+
<CollapsiblePrompt title="Instructions (task prompt)" text={resp?.task || task || ""} />
|
|
255
386
|
</div>
|
|
256
|
-
{(resp?.task || task) && (
|
|
257
|
-
<div className="taskbox-side">
|
|
258
|
-
<div className="taskbox-side-label">task</div>
|
|
259
|
-
{resp?.task || task}
|
|
260
|
-
</div>
|
|
261
|
-
)}
|
|
262
|
-
{demo && <div className="msg">Session transcripts hidden in demo mode.</div>}
|
|
263
|
-
{(resp?.entries ?? []).map((e, i) => (
|
|
264
|
-
<div className="msg" key={i}>
|
|
265
|
-
<div className={"role role-" + e.role}>{e.role}</div>
|
|
266
|
-
{e.text}
|
|
267
|
-
</div>
|
|
268
|
-
))}
|
|
269
387
|
</div>
|
|
270
388
|
);
|
|
271
389
|
}
|
|
@@ -451,7 +569,7 @@ function Flow() {
|
|
|
451
569
|
// so drag positions and measured sizes (needed by the minimap) survive polling.
|
|
452
570
|
useEffect(() => {
|
|
453
571
|
if (!payload) { setNodes([]); return; }
|
|
454
|
-
const pos = computePositions(payload);
|
|
572
|
+
const pos = computePositions(payload.nodes, payload.edges);
|
|
455
573
|
const gateId = payload.loop
|
|
456
574
|
? (payload.nodes.find((n) => n.id === payload.loop!.gate) ?? payload.nodes.find((n) => n.type === payload.loop!.gate))?.id
|
|
457
575
|
: undefined;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const NODE_W = 284;
|
|
2
|
+
export const NODE_H_GAP = 200;
|
|
3
|
+
export const NODE_W_GAP = 40;
|
|
4
|
+
|
|
5
|
+
export function excerptText(text: string, max: number): { excerpt: string; truncated: boolean } {
|
|
6
|
+
const truncated = text.length > max;
|
|
7
|
+
return { excerpt: truncated ? text.slice(0, max) + "…" : text, truncated };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function topoLayers(ids: string[], edges: Array<{ from: string; to: string }>): string[][] {
|
|
11
|
+
const indeg: Record<string, number> = {};
|
|
12
|
+
const nextById: Record<string, string[]> = {};
|
|
13
|
+
for (const id of ids) {
|
|
14
|
+
indeg[id] = 0;
|
|
15
|
+
nextById[id] = [];
|
|
16
|
+
}
|
|
17
|
+
for (const edge of edges) {
|
|
18
|
+
if (!(edge.from in indeg) || !(edge.to in indeg)) continue;
|
|
19
|
+
indeg[edge.to]++;
|
|
20
|
+
nextById[edge.from].push(edge.to);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const layers: string[][] = [];
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
let cur = ids.filter((id) => indeg[id] === 0);
|
|
26
|
+
while (cur.length) {
|
|
27
|
+
layers.push(cur);
|
|
28
|
+
const next: string[] = [];
|
|
29
|
+
for (const id of cur) {
|
|
30
|
+
seen.add(id);
|
|
31
|
+
for (const to of nextById[id]) {
|
|
32
|
+
indeg[to]--;
|
|
33
|
+
if (indeg[to] === 0) next.push(to);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
cur = next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const id of ids) {
|
|
40
|
+
if (seen.has(id)) continue;
|
|
41
|
+
layers.push([id]);
|
|
42
|
+
seen.add(id);
|
|
43
|
+
}
|
|
44
|
+
return layers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function median(values: number[]): number {
|
|
48
|
+
if (!values.length) return 0;
|
|
49
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
50
|
+
const mid = Math.floor(sorted.length / 2);
|
|
51
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function reduceCrossings(layers: string[][], edges: Array<{ from: string; to: string }>): string[][] {
|
|
55
|
+
const ordered = layers.map((layer) => layer.slice());
|
|
56
|
+
for (let li = 1; li < ordered.length; li++) {
|
|
57
|
+
const prevPos: Record<string, number> = {};
|
|
58
|
+
ordered[li - 1].forEach((id, i) => {
|
|
59
|
+
prevPos[id] = i;
|
|
60
|
+
});
|
|
61
|
+
const key = (id: string) => median(edges.filter((edge) => edge.to === id).map((edge) => prevPos[edge.from] ?? 0));
|
|
62
|
+
ordered[li].sort((a, b) => key(a) - key(b) || a.localeCompare(b));
|
|
63
|
+
}
|
|
64
|
+
return ordered;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function computePositions(
|
|
68
|
+
nodes: Array<{ id: string }>,
|
|
69
|
+
edges: Array<{ from: string; to: string }>,
|
|
70
|
+
): Record<string, { x: number; y: number }> {
|
|
71
|
+
const layers = reduceCrossings(topoLayers(nodes.map((node) => node.id), edges), edges);
|
|
72
|
+
const maxLayerLen = layers.reduce((max, layer) => Math.max(max, layer.length), 0);
|
|
73
|
+
const stepX = NODE_W + NODE_W_GAP;
|
|
74
|
+
const pos: Record<string, { x: number; y: number }> = {};
|
|
75
|
+
|
|
76
|
+
layers.forEach((layer, li) => {
|
|
77
|
+
const xOffset = ((maxLayerLen - layer.length) * stepX) / 2;
|
|
78
|
+
layer.forEach((id, ni) => {
|
|
79
|
+
pos[id] = { x: xOffset + ni * stepX, y: li * NODE_H_GAP };
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
return pos;
|
|
83
|
+
}
|