ltcai 1.7.0 → 2.1.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.
@@ -0,0 +1,428 @@
1
+ # Lattice AI Multi-Agent Runtime 2.1
2
+
3
+ The Multi-Agent Runtime is the **orchestration layer** introduced in v2.0.0 and
4
+ operationalized in v2.1.0. It sits
5
+ *above* the v1.x single-agent state machine ([`AgentRuntime`](../latticeai/core/agent.py))
6
+ and coordinates a pipeline of named **roles** that hand off work to one another,
7
+ retry on a failing review, and emit a fully observable, replayable timeline.
8
+
9
+ - **Source of truth:** `latticeai/core/multi_agent.py`
10
+ - **HTTP surface:** `latticeai/api/agents.py`
11
+ - **Persistence / Knowledge Graph integration:** `latticeai/core/workspace_os.py`
12
+ (`WorkspaceOSStore.record_agent_run`, `replay_agent_run`, `list_handoffs`)
13
+
14
+ ```python
15
+ MULTI_AGENT_VERSION = "2.1.0"
16
+ ```
17
+
18
+ ## What v2.1 adds
19
+
20
+ v2.1 does not replace the v2.0 runtime; it makes the runtime's operational
21
+ objects durable and inspectable:
22
+
23
+ - **Explicit handoff records**: `handoff_id`, source/target agent ids, reason,
24
+ task summary, context packet, status, and timestamps.
25
+ - **Agent context packets**: objective, task summary, workspace/graph/memory/
26
+ workflow context, plugin outputs, constraints, reviewer notes, and retry
27
+ metadata with obvious secret keys redacted.
28
+ - **Review / retry history**: reviewer outcomes normalize to `approve`,
29
+ `reject`, or `retry`; retry reasons, notes, counts, and limits are persisted.
30
+ - **Planning records**: plans include a `plan_id`, ordered executable steps, and
31
+ plan-review metadata.
32
+ - **Replay**: persisted runs can be replayed as frames showing actor, time,
33
+ reason, input, output, and decision via `/agents/api/runs/{run_id}/replay`.
34
+
35
+ ## How it relates to the v1 single-agent runtime
36
+
37
+ v1.x shipped a single-agent state machine — `AgentRuntime` driving
38
+ `PLAN → EXECUTE → VERIFY → DONE` (with `ROLLBACK` / `FAILED` recovery paths) over an
39
+ injected `AgentDeps` port. That runtime is unchanged.
40
+
41
+ v2.0 adds the *orchestration* layer on top: instead of one agent looping through
42
+ internal phases, the `MultiAgentOrchestrator` drives a **pipeline of distinct roles**
43
+ (researcher, planner, executor, reviewer, release) that hand off to one another and
44
+ can rewind on a failing review. The two layers are complementary — the v2
45
+ orchestrator coordinates roles; an individual role's runner could itself be backed by
46
+ the v1 `AgentRuntime` loop or an LLM, but that is an implementation choice behind the
47
+ injected runner port.
48
+
49
+ > **Compatibility.** The Multi-Agent Runtime is purely additive. The v1
50
+ > `AgentRuntime` state machine and its `/agent` endpoints are untouched, and the v2
51
+ > API is namespaced under `/agents` (plural) so it never collides with the existing
52
+ > single-agent `/agent` routes. Existing v1.x data is preserved; new runs are appended
53
+ > to workspace state and the Knowledge Graph alongside it.
54
+
55
+ ## Built-in roles (`AGENT_ROLES`)
56
+
57
+ The runtime defines five built-in roles. Each role id matches an entry in
58
+ `latticeai.core.workspace_os.DEFAULT_AGENTS`, so orchestrated runs reference the same
59
+ agents that already appear in the Workspace.
60
+
61
+ ```python
62
+ AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
63
+
64
+ ROLE_AGENT_IDS = {
65
+ "researcher": "agent:researcher",
66
+ "planner": "agent:planner",
67
+ "executor": "agent:executor",
68
+ "reviewer": "agent:reviewer",
69
+ "release": "agent:release",
70
+ }
71
+ ```
72
+
73
+ | Role | Agent id | Responsibility |
74
+ | --- | --- | --- |
75
+ | `researcher` | `agent:researcher` | Gathers relevant context (workspace memory / graph) via an injected `context_provider`. |
76
+ | `planner` | `agent:planner` | Decomposes the goal into ordered, inspectable steps. |
77
+ | `executor` | `agent:executor` | Carries out steps; may drive an injected workflow / plugin runner. |
78
+ | `reviewer` | `agent:reviewer` | Judges the result and returns a `pass` / `retry` verdict. |
79
+ | `release` | `agent:release` | Finalizes / packages the outcome (optional). |
80
+
81
+ The `agent:*` ids correspond one-to-one with `DEFAULT_AGENTS` in `workspace_os`
82
+ (`agent:planner`, `agent:executor`, `agent:reviewer`, `agent:researcher`,
83
+ `agent:release`), which is why an orchestrated run can record relationships against
84
+ agents that the Workspace already knows about.
85
+
86
+ ### The core pipeline
87
+
88
+ `researcher` and `release` are optional stages — they run only when explicitly
89
+ requested. A quick, default run is therefore three stages:
90
+
91
+ ```python
92
+ CORE_PIPELINE = ("planner", "executor", "reviewer")
93
+ ```
94
+
95
+ When `MultiAgentOrchestrator.run(...)` is called without an explicit `roles` list, it
96
+ uses `CORE_PIPELINE`. Any roles passed in are filtered to the set of known
97
+ `AGENT_ROLES`; if nothing valid remains, it falls back to `CORE_PIPELINE`.
98
+
99
+ ## Orchestration: `MultiAgentOrchestrator.run`
100
+
101
+ ```python
102
+ def run(
103
+ self,
104
+ goal: str,
105
+ *,
106
+ user_email: Optional[str] = None,
107
+ workspace_id: Optional[str] = None,
108
+ inputs: Optional[Dict[str, Any]] = None,
109
+ roles: Optional[List[str]] = None,
110
+ max_retries: int = 2,
111
+ ) -> AgentRunResult:
112
+ ...
113
+ ```
114
+
115
+ `run` walks the resolved pipeline, threading a single `OrchestrationContext` through
116
+ every stage. As it goes it appends two kinds of events to an observable timeline:
117
+
118
+ - **`role` events** — emitted by `_run_role` for each stage, recording the role, its
119
+ `agent_id`, status (`ok` / `error`), the raw runner result, and start/end timestamps.
120
+ - **`handoff` events** — emitted via `OrchestrationContext.handoff(frm, to, note)`
121
+ whenever control passes from one role to the next (and on a retry rewind).
122
+
123
+ The timeline is also bracketed by a `start` event (carrying the goal and resolved
124
+ pipeline) and an `end` event (carrying the final status and retry count).
125
+
126
+ ### Retry: reviewer rewinds to executor
127
+
128
+ The pipeline is ordinarily linear, but the **reviewer can rewind** the pipeline to the
129
+ executor. After the reviewer runs, if its verdict is `retry` and the retry budget is
130
+ not yet exhausted, the orchestrator:
131
+
132
+ 1. increments `ctx.retries`,
133
+ 2. emits a `handoff("reviewer", "executor", note="retry #N: <reason>")`,
134
+ 3. resets the pipeline index back to the executor stage, and
135
+ 4. re-runs executor → reviewer.
136
+
137
+ This repeats until the verdict is `pass` or `ctx.retries` reaches `max_retries`.
138
+
139
+ ```text
140
+ planner → executor → reviewer ──pass──▶ (continue / end)
141
+
142
+ └──retry (and retries < max_retries)──▶ executor (rewind)
143
+ ```
144
+
145
+ ### Final status
146
+
147
+ The terminal status is derived from the final reviewer verdict and retry count:
148
+
149
+ | Condition | `status` |
150
+ | --- | --- |
151
+ | Final verdict `pass`, no retries | `ok` |
152
+ | Final verdict `pass`, after one or more retries | `retried_ok` |
153
+ | Final verdict not `pass` (retries exhausted) | `failed` |
154
+
155
+ ### `OrchestrationContext`
156
+
157
+ The mutable carrier threaded through every stage:
158
+
159
+ ```python
160
+ @dataclass
161
+ class OrchestrationContext:
162
+ goal: str
163
+ user_email: Optional[str] = None
164
+ workspace_id: Optional[str] = None
165
+ inputs: Dict[str, Any] = field(default_factory=dict)
166
+ plan: List[Dict[str, Any]] = field(default_factory=list)
167
+ research: List[str] = field(default_factory=list)
168
+ executed: List[Dict[str, Any]] = field(default_factory=list)
169
+ review: Dict[str, Any] = field(default_factory=dict)
170
+ timeline: List[Dict[str, Any]] = field(default_factory=list)
171
+ retries: int = 0
172
+ output: str = ""
173
+
174
+ def handoff(self, frm: str, to: str, note: str = "") -> None: ...
175
+ ```
176
+
177
+ ### `AgentRunResult`
178
+
179
+ `run` returns an `AgentRunResult`, which exposes `as_dict()` for serialization:
180
+
181
+ ```python
182
+ @dataclass
183
+ class AgentRunResult:
184
+ agent_id: str
185
+ status: str # ok | failed | retried_ok
186
+ output: str
187
+ timeline: List[Dict[str, Any]]
188
+ plan: List[Dict[str, Any]]
189
+ review: Dict[str, Any]
190
+ roles_run: List[str]
191
+ retries: int = 0
192
+ ```
193
+
194
+ ## The role runner is an injected port
195
+
196
+ Like the v1 runtime, the orchestrator is **pure logic over an injected `role_runner`
197
+ port**. It runs with no LLM and no server:
198
+
199
+ ```python
200
+ class MultiAgentOrchestrator:
201
+ def __init__(
202
+ self,
203
+ role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None,
204
+ ):
205
+ self.role_runner = role_runner or default_role_runner()
206
+ ```
207
+
208
+ The runner is a single callable `(role: str, ctx: OrchestrationContext) -> Dict[str, Any]`.
209
+ The orchestration logic — pipeline walking, handoffs, retry rewind, timeline emission,
210
+ status derivation — does not depend on *how* a role does its work. This means a
211
+ **production deployment can swap in an LLM-backed runner without touching the
212
+ orchestration layer**: implement the same callable signature, pass it to the
213
+ constructor, and the pipeline behaves identically while individual roles gain
214
+ model-backed reasoning.
215
+
216
+ If a runner raises, `_run_role` captures the exception into the `role` event as
217
+ `status: "error"` with an `{"error": ...}` result, rather than crashing the run.
218
+
219
+ ## The default runner is deterministic and useful
220
+
221
+ `default_role_runner` builds a **dependency-free, deterministic** runner that
222
+ implements every built-in role with real (non-LLM) behavior. This is what makes
223
+ "agent runs can execute workflows / plugins" true in the community edition without
224
+ requiring a model.
225
+
226
+ ```python
227
+ def default_role_runner(
228
+ *,
229
+ workflow_runner: Optional[Callable[..., Any]] = None,
230
+ plugin_runner: Optional[Callable[..., Any]] = None,
231
+ context_provider: Optional[Callable[[str], List[str]]] = None,
232
+ ) -> Callable[[str, OrchestrationContext], Dict[str, Any]]:
233
+ ...
234
+ ```
235
+
236
+ Behavior by role:
237
+
238
+ - **`researcher`** — calls the injected `context_provider(goal)` (workspace memory) to
239
+ pull relevant context into `ctx.research`; returns the count and the first items.
240
+ - **`planner`** — decomposes the goal into ordered steps. If `inputs["steps"]` is a
241
+ non-empty list, each entry becomes a planned step; otherwise it produces a default
242
+ three-step plan (`Analyze` / `Execute` / `Verify the result`). Steps are written to
243
+ `ctx.plan`.
244
+ - **`executor`** — iterates the plan and marks each step `done`. A step may request a
245
+ workflow or plugin run; when the corresponding runner is injected, the executor
246
+ drives it (see below). Results are written to `ctx.executed` and a summary line to
247
+ `ctx.output`.
248
+ - **`reviewer`** — passes only if `ctx.executed` is non-empty and *every* executed step
249
+ has `status == "done"`; otherwise it returns `retry`. The verdict shape is:
250
+
251
+ ```json
252
+ {
253
+ "verdict": "pass",
254
+ "reason": "all steps completed",
255
+ "confidence": 0.9
256
+ }
257
+ ```
258
+
259
+ A failing review yields `{"verdict": "retry", "reason": "no steps executed", "confidence": 0.3}`.
260
+ - **`release`** — sets/keeps `ctx.output` and returns `{"released": true, "summary": ...}`.
261
+
262
+ An unrecognized role returns `{"role": role, "status": "noop"}`.
263
+
264
+ ### Agent → workflow and agent → plugin integration
265
+
266
+ The executor role is where Lattice's cross-feature integration happens. When the
267
+ `default_role_runner` is built with a `workflow_runner` and/or `plugin_runner`, a plan
268
+ step can drive them:
269
+
270
+ - A step's `workflow` (or `inputs["workflow"]`) is run via `workflow_runner(wf, ctx)`
271
+ on the first step (`index == 0`), capturing the result under `workflow_result` (or
272
+ `workflow_error` on failure).
273
+ - A step's `plugin` is run via `plugin_runner(pl, ctx)`, capturing `plugin_result` (or
274
+ `plugin_error`).
275
+
276
+ This is the **agent → workflow** and **agent → plugin** seam: an orchestrated agent run
277
+ can actually execute Workflows and Plugins, in the community edition, with no model
278
+ required.
279
+
280
+ ## Persistence and Knowledge Graph
281
+
282
+ After a run completes, the API persists it via
283
+ `WorkspaceOSStore.record_agent_run`, which both ingests a Knowledge Graph node and
284
+ records a Workspace timeline event:
285
+
286
+ ```python
287
+ def record_agent_run(
288
+ self,
289
+ *,
290
+ agent_id: str,
291
+ status: str,
292
+ input_text: str,
293
+ output_text: str,
294
+ user_email: Optional[str],
295
+ timeline: Optional[List[Dict[str, Any]]] = None,
296
+ relationships: Optional[List[str]] = None,
297
+ graph: Any = None,
298
+ workspace_id: Optional[str] = None,
299
+ ) -> Dict[str, Any]:
300
+ ...
301
+ ```
302
+
303
+ What it does:
304
+
305
+ - Builds a run record (`id`, `agent_id`, `status`, `input`, `output_preview` truncated
306
+ to 1000 chars, `user_email`, scoped `workspace_id`, `relationships`, `timeline`,
307
+ `created_at`).
308
+ - When a `graph` is supplied, calls `graph.ingest_event("AgentRun", ...)` and stores the
309
+ returned `graph_node_id` on the run (capturing `graph_error` if ingest fails).
310
+ - Appends the run to workspace state (retaining the most recent 300) and records an
311
+ `agent` / `agent_run` timeline event.
312
+
313
+ Runs are workspace-scoped; `list_agents(workspace_id=...)` returns the registered
314
+ `agents` plus the most recent runs for that scope.
315
+
316
+ ## HTTP API
317
+
318
+ The router is created by `create_agents_router(...)` in `latticeai/api/agents.py`. All
319
+ paths live under `/agents` (plural).
320
+
321
+ > **Compatibility.** `/agents` does **not** collide with the existing single-agent
322
+ > `/agent` endpoints — the trailing `s` keeps the v2 namespace fully separate, so v1
323
+ > clients continue to work unchanged.
324
+
325
+ ### `GET /agents` — UI page
326
+
327
+ Requires an authenticated user. Serves the `agents.html` Multi-Agent UI from the static
328
+ directory; returns `404` if the UI is not available or not found.
329
+
330
+ ### `GET /agents/api/roles`
331
+
332
+ Requires an authenticated user. Lists the built-in roles and the default pipeline.
333
+
334
+ ```json
335
+ {
336
+ "roles": [
337
+ {"role": "researcher", "agent_id": "agent:researcher"},
338
+ {"role": "planner", "agent_id": "agent:planner"},
339
+ {"role": "executor", "agent_id": "agent:executor"},
340
+ {"role": "reviewer", "agent_id": "agent:reviewer"},
341
+ {"role": "release", "agent_id": "agent:release"}
342
+ ],
343
+ "default_pipeline": ["planner", "executor", "reviewer"]
344
+ }
345
+ ```
346
+
347
+ ### `GET /agents/api/runs`
348
+
349
+ Requires an authenticated user; reads are scoped via `gate_read`. Returns the registered
350
+ agents plus recent runs for the resolved workspace scope (the result of
351
+ `store.list_agents(workspace_id=scope)`).
352
+
353
+ ```json
354
+ {
355
+ "agents": [ { "id": "agent:planner", "name": "Planner", "...": "..." } ],
356
+ "runs": [ { "id": "agent-run-...", "agent_id": "agent:executor", "status": "ok", "...": "..." } ]
357
+ }
358
+ ```
359
+
360
+ ### `POST /agents/api/run`
361
+
362
+ Requires an authenticated user; writes are scoped via `gate_write`. Runs the
363
+ orchestrator and persists the result.
364
+
365
+ **Request body** (`AgentRunRequest`):
366
+
367
+ ```json
368
+ {
369
+ "goal": "Summarize the open incidents and draft a status update",
370
+ "roles": ["researcher", "planner", "executor", "reviewer"],
371
+ "inputs": { "steps": ["Collect incidents", "Draft update"] },
372
+ "max_retries": 2
373
+ }
374
+ ```
375
+
376
+ | Field | Type | Default | Notes |
377
+ | --- | --- | --- | --- |
378
+ | `goal` | string | — | Required; a `400` is returned if blank. |
379
+ | `roles` | string[] | `[]` | Empty means the default `CORE_PIPELINE`. Unknown roles are filtered out. |
380
+ | `inputs` | object | `{}` | Passed through to the runner (e.g. `steps`, `workflow`). |
381
+ | `max_retries` | int | `2` | Clamped server-side to the range `0`–`5`. |
382
+
383
+ The endpoint resolves an orchestrator via the injected `orchestrator_factory(user, scope)`,
384
+ runs it, records the run with `store.record_agent_run(...)` (passing the
385
+ `workspace_graph()` for KG ingest and the run's `roles_run` as `relationships`), and
386
+ appends a `multi_agent_run` audit event.
387
+
388
+ **Response:**
389
+
390
+ ```json
391
+ {
392
+ "run": {
393
+ "id": "agent-run-…",
394
+ "agent_id": "agent:executor",
395
+ "status": "ok",
396
+ "input": "Summarize the open incidents and draft a status update",
397
+ "output_preview": "Completed 2 planned step(s) for: …",
398
+ "relationships": ["agent:researcher", "agent:planner", "agent:executor", "agent:reviewer"],
399
+ "timeline": [ { "event": "start", "...": "..." } ],
400
+ "graph_node_id": "…",
401
+ "created_at": "…"
402
+ },
403
+ "result": {
404
+ "agent_id": "agent:executor",
405
+ "status": "ok",
406
+ "output": "Completed 2 planned step(s) for: …",
407
+ "timeline": [ "…" ],
408
+ "plan": [ { "index": 0, "description": "Collect incidents", "status": "done" } ],
409
+ "review": { "verdict": "pass", "reason": "all steps completed", "confidence": 0.9 },
410
+ "roles_run": ["researcher", "planner", "executor", "reviewer"],
411
+ "retries": 0
412
+ }
413
+ }
414
+ ```
415
+
416
+ ## Timeline event reference
417
+
418
+ The orchestration timeline is a flat, ordered list of event objects. Event types:
419
+
420
+ | `event` | Emitted by | Key fields |
421
+ | --- | --- | --- |
422
+ | `start` | `run` (before the pipeline) | `goal`, `pipeline`, `timestamp` |
423
+ | `role` | `_run_role` (per stage) | `role`, `agent_id`, `status`, `result`, `started_at`, `timestamp` |
424
+ | `handoff` | `OrchestrationContext.handoff` | `from`, `to`, `note`, `timestamp` |
425
+ | `end` | `run` (after the pipeline) | `status`, `retries`, `timestamp` |
426
+
427
+ This timeline is returned on the run result and persisted with the run, so it drops
428
+ straight into the Workspace timeline and Knowledge Graph.