ltcai 4.3.3 → 4.5.1
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 +53 -20
- package/docs/CHANGELOG.md +122 -0
- package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
- package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
- package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
- package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
- package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
- package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
- package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
- package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
- package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
- package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
- package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
- package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
- package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
- package/docs/V4_5_1_UX_REPORT.md +45 -0
- package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
- package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -16
- package/docs/architecture.md +8 -4
- package/frontend/src/App.tsx +152 -91
- package/frontend/src/api/client.ts +83 -1
- package/frontend/src/components/FirstRunGuide.tsx +99 -0
- package/frontend/src/components/primitives.tsx +131 -25
- package/frontend/src/components/ui/badge.tsx +2 -2
- package/frontend/src/components/ui/button.tsx +7 -7
- package/frontend/src/components/ui/card.tsx +5 -5
- package/frontend/src/components/ui/input.tsx +1 -1
- package/frontend/src/components/ui/textarea.tsx +1 -1
- package/frontend/src/pages/Act.tsx +58 -28
- package/frontend/src/pages/Ask.tsx +51 -19
- package/frontend/src/pages/Brain.tsx +60 -42
- package/frontend/src/pages/Capture.tsx +24 -24
- package/frontend/src/pages/Library.tsx +222 -32
- package/frontend/src/pages/System.tsx +56 -34
- package/frontend/src/routes.ts +15 -13
- package/frontend/src/store/appStore.ts +8 -1
- package/frontend/src/styles.css +666 -36
- package/lattice_brain/__init__.py +38 -23
- package/lattice_brain/_kg_common.py +11 -1
- package/lattice_brain/context.py +212 -2
- package/lattice_brain/conversations.py +234 -1
- package/lattice_brain/discovery.py +11 -1
- package/lattice_brain/documents.py +11 -1
- package/lattice_brain/graph/__init__.py +28 -0
- package/lattice_brain/graph/_kg_common.py +1123 -0
- package/lattice_brain/graph/curator.py +473 -0
- package/lattice_brain/graph/discovery.py +1455 -0
- package/lattice_brain/graph/documents.py +218 -0
- package/lattice_brain/graph/identity.py +175 -0
- package/lattice_brain/graph/ingest.py +644 -0
- package/lattice_brain/graph/network.py +205 -0
- package/lattice_brain/graph/projection.py +571 -0
- package/lattice_brain/graph/provenance.py +401 -0
- package/lattice_brain/graph/retrieval.py +1341 -0
- package/lattice_brain/graph/schema.py +640 -0
- package/lattice_brain/graph/store.py +237 -0
- package/lattice_brain/graph/write_master.py +225 -0
- package/lattice_brain/identity.py +11 -13
- package/lattice_brain/ingest.py +11 -1
- package/lattice_brain/ingestion.py +318 -0
- package/lattice_brain/memory.py +100 -1
- package/lattice_brain/network.py +11 -1
- package/lattice_brain/portability.py +431 -0
- package/lattice_brain/projection.py +11 -1
- package/lattice_brain/provenance.py +11 -1
- package/lattice_brain/retrieval.py +11 -1
- package/lattice_brain/runtime/__init__.py +32 -0
- package/lattice_brain/runtime/agent_runtime.py +569 -0
- package/lattice_brain/runtime/hooks.py +754 -0
- package/lattice_brain/runtime/multi_agent.py +795 -0
- package/lattice_brain/schema.py +11 -1
- package/lattice_brain/store.py +10 -2
- package/lattice_brain/workflow.py +461 -0
- package/lattice_brain/write_master.py +11 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +2 -2
- package/latticeai/api/browser.py +1 -1
- package/latticeai/api/chat.py +1 -1
- package/latticeai/api/computer_use.py +1 -1
- package/latticeai/api/hooks.py +2 -2
- package/latticeai/api/mcp.py +1 -1
- package/latticeai/api/models.py +107 -18
- package/latticeai/api/tools.py +1 -1
- package/latticeai/api/workflow_designer.py +2 -2
- package/latticeai/app_factory.py +4 -4
- package/latticeai/brain/__init__.py +24 -6
- package/latticeai/brain/_kg_common.py +11 -1117
- package/latticeai/brain/context.py +12 -208
- package/latticeai/brain/conversations.py +12 -231
- package/latticeai/brain/discovery.py +13 -1451
- package/latticeai/brain/documents.py +13 -214
- package/latticeai/brain/identity.py +11 -169
- package/latticeai/brain/ingest.py +13 -640
- package/latticeai/brain/memory.py +12 -97
- package/latticeai/brain/network.py +12 -200
- package/latticeai/brain/projection.py +13 -567
- package/latticeai/brain/provenance.py +13 -397
- package/latticeai/brain/retrieval.py +13 -1337
- package/latticeai/brain/schema.py +12 -635
- package/latticeai/brain/store.py +13 -233
- package/latticeai/brain/write_master.py +13 -221
- package/latticeai/core/agent.py +1 -1
- package/latticeai/core/agent_registry.py +2 -2
- package/latticeai/core/builtin_hooks.py +2 -2
- package/latticeai/core/graph_curator.py +6 -468
- package/latticeai/core/hooks.py +6 -749
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/model_compat.py +250 -0
- package/latticeai/core/multi_agent.py +6 -790
- package/latticeai/core/workflow_engine.py +6 -456
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +136 -32
- package/latticeai/services/agent_runtime.py +6 -564
- package/latticeai/services/ingestion.py +6 -313
- package/latticeai/services/kg_portability.py +6 -426
- package/latticeai/services/model_catalog.py +2 -2
- package/latticeai/services/model_recommendation.py +8 -1
- package/latticeai/services/model_runtime.py +18 -3
- package/latticeai/services/platform_runtime.py +3 -3
- package/latticeai/services/run_executor.py +1 -1
- package/latticeai/services/upload_service.py +1 -1
- package/p_reinforce.py +1 -1
- package/package.json +1 -1
- package/scripts/build_frontend_assets.mjs +12 -1
- package/scripts/bump_version.py +1 -1
- package/scripts/wheel_smoke.py +7 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-3G8qcrIS.js +336 -0
- package/static/app/assets/index-3G8qcrIS.js.map +1 -0
- package/static/app/assets/index-C0wYZp7k.css +2 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-CHHal8Zl.css +0 -2
- package/static/app/assets/index-pdzil9ac.js +0 -333
- package/static/app/assets/index-pdzil9ac.js.map +0 -1
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""AgentRuntime — the single boundary for agent execution and observability.
|
|
2
|
+
|
|
3
|
+
Before this module the agent concern was spread across three places: the
|
|
4
|
+
:class:`~latticeai.core.multi_agent.MultiAgentOrchestrator` (role pipeline),
|
|
5
|
+
the :class:`~latticeai.services.platform_runtime.PlatformRuntime` (cross-system
|
|
6
|
+
wiring + an ad-hoc ``run_agent``), and ``api/agents.py`` (HTTP transport that
|
|
7
|
+
also owned orchestration + persistence + audit). The frontend reached past all
|
|
8
|
+
of them into the workspace store via ``/workspace/agents``.
|
|
9
|
+
|
|
10
|
+
``AgentRuntime`` collapses that into one façade with a small, stable surface:
|
|
11
|
+
|
|
12
|
+
* **configuration** — :meth:`config`, :meth:`roles`
|
|
13
|
+
* **status / health** — :meth:`status`, :meth:`health`
|
|
14
|
+
* **execution** — :meth:`start`, :meth:`stop`
|
|
15
|
+
* **events / state** — :meth:`list_runs`, :meth:`get_run`, :meth:`events`, :meth:`replay`
|
|
16
|
+
|
|
17
|
+
It *wraps* the existing orchestrator and run store rather than reimplementing
|
|
18
|
+
them — execution semantics are unchanged, but every caller (HTTP router and, via
|
|
19
|
+
it, the frontend) now depends on this boundary instead of internal paths.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
from .multi_agent import (
|
|
28
|
+
AGENT_ROLES,
|
|
29
|
+
CORE_PIPELINE,
|
|
30
|
+
MULTI_AGENT_VERSION,
|
|
31
|
+
ROLE_AGENT_IDS,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ROLE_DESCRIPTIONS = {
|
|
35
|
+
"researcher": "Gathers workspace context and memory for the goal.",
|
|
36
|
+
"planner": "Decomposes the goal into an ordered, bounded plan.",
|
|
37
|
+
"executor": "Executes each planned step, invoking tools and workflows.",
|
|
38
|
+
"reviewer": "Reviews the executed work and approves, rejects, or retries.",
|
|
39
|
+
"release": "Finalizes and summarizes the approved outcome.",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Run statuses the orchestrator can emit that mean "still working". The default
|
|
43
|
+
# orchestrator runs synchronously, so persisted runs are always terminal; this
|
|
44
|
+
# set lets the runtime report live work if a future async runner lands.
|
|
45
|
+
_ACTIVE_STATUSES = {"running", "in_progress", "queued", "retrying", "cancelling"}
|
|
46
|
+
_TERMINAL_STATUSES = {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now() -> str:
|
|
50
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AgentRuntimeUnavailable(RuntimeError):
|
|
54
|
+
"""Raised when a product run would otherwise persist simulation output."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AgentRuntime:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
store: Any,
|
|
62
|
+
orchestrator_factory: Callable[[Optional[str], Optional[str]], Any],
|
|
63
|
+
workspace_graph: Callable[[], Any],
|
|
64
|
+
append_audit_event: Callable[..., None],
|
|
65
|
+
max_retries_cap: int = 5,
|
|
66
|
+
hooks: Any = None,
|
|
67
|
+
allow_simulation_runs: bool = False,
|
|
68
|
+
):
|
|
69
|
+
self._store = store
|
|
70
|
+
self._orchestrator_factory = orchestrator_factory
|
|
71
|
+
self._workspace_graph = workspace_graph
|
|
72
|
+
self._append_audit_event = append_audit_event
|
|
73
|
+
self._max_retries_cap = int(max_retries_cap)
|
|
74
|
+
# Lifecycle hooks registry (optional). When present, ``start`` fires the
|
|
75
|
+
# ``pre_run`` / ``post_run`` hooks; a blocking ``pre_run`` aborts the run.
|
|
76
|
+
self._hooks = hooks
|
|
77
|
+
self._allow_simulation_runs = bool(allow_simulation_runs)
|
|
78
|
+
self._run_executor: Any = None
|
|
79
|
+
|
|
80
|
+
def attach_executor(self, executor: Any) -> None:
|
|
81
|
+
self._run_executor = executor
|
|
82
|
+
|
|
83
|
+
def _execution_mode(self) -> str:
|
|
84
|
+
return "async" if self._run_executor is not None else "synchronous"
|
|
85
|
+
|
|
86
|
+
# ── configuration ─────────────────────────────────────────────────────
|
|
87
|
+
def config(self) -> Dict[str, Any]:
|
|
88
|
+
return {
|
|
89
|
+
"version": MULTI_AGENT_VERSION,
|
|
90
|
+
"roles": list(AGENT_ROLES),
|
|
91
|
+
"default_pipeline": list(CORE_PIPELINE),
|
|
92
|
+
"max_retries_cap": self._max_retries_cap,
|
|
93
|
+
"execution_mode": self._execution_mode(),
|
|
94
|
+
"simulation_runs_allowed": self._allow_simulation_runs,
|
|
95
|
+
"cancellation": (
|
|
96
|
+
"cooperative; running synchronous model/tool calls finish their current step before a cancelled status is persisted"
|
|
97
|
+
if self._run_executor is not None else
|
|
98
|
+
"not supported for the synchronous runtime"
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def roles(self) -> List[Dict[str, Any]]:
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
"role": role,
|
|
106
|
+
"agent_id": ROLE_AGENT_IDS.get(role, f"agent:{role}"),
|
|
107
|
+
"description": ROLE_DESCRIPTIONS.get(role, ""),
|
|
108
|
+
"terminal": role not in {"researcher", "planner", "executor", "reviewer"},
|
|
109
|
+
}
|
|
110
|
+
for role in AGENT_ROLES
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# ── health ────────────────────────────────────────────────────────────
|
|
114
|
+
def health(self) -> Dict[str, Any]:
|
|
115
|
+
checks: Dict[str, Any] = {}
|
|
116
|
+
ok = True
|
|
117
|
+
ready = True
|
|
118
|
+
try:
|
|
119
|
+
self._store.list_agents(workspace_id=None)
|
|
120
|
+
checks["run_store"] = {"status": "ok"}
|
|
121
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
122
|
+
ok = False
|
|
123
|
+
checks["run_store"] = {"status": "error", "detail": str(exc)}
|
|
124
|
+
try:
|
|
125
|
+
orchestrator = self._orchestrator_factory(None, None)
|
|
126
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
127
|
+
if mode == "simulation":
|
|
128
|
+
if self._allow_simulation_runs:
|
|
129
|
+
checks["orchestrator"] = {
|
|
130
|
+
"status": "ok",
|
|
131
|
+
"mode": mode,
|
|
132
|
+
"detail": "Simulation runs are explicitly enabled for this non-product runtime.",
|
|
133
|
+
}
|
|
134
|
+
else:
|
|
135
|
+
ready = False
|
|
136
|
+
checks["orchestrator"] = {
|
|
137
|
+
"status": "unavailable",
|
|
138
|
+
"mode": mode,
|
|
139
|
+
"detail": "No LLM-backed model is loaded; product execution API refuses simulation runs.",
|
|
140
|
+
}
|
|
141
|
+
else:
|
|
142
|
+
checks["orchestrator"] = {"status": "ok", "mode": mode}
|
|
143
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
144
|
+
ok = False
|
|
145
|
+
checks["orchestrator"] = {"status": "error", "detail": str(exc)}
|
|
146
|
+
return {
|
|
147
|
+
"status": "ok" if ok and ready else "unavailable" if ok else "degraded",
|
|
148
|
+
"ready": bool(ok and ready),
|
|
149
|
+
"checks": checks,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def _live_orchestrator(self, user_email: Optional[str], scope: Optional[str]) -> Any:
|
|
153
|
+
orchestrator = self._orchestrator_factory(user_email or None, scope)
|
|
154
|
+
mode = getattr(orchestrator, "mode", "simulation")
|
|
155
|
+
if mode == "simulation" and not self._allow_simulation_runs:
|
|
156
|
+
raise AgentRuntimeUnavailable(
|
|
157
|
+
"Agent execution is unavailable because no LLM-backed model is loaded. "
|
|
158
|
+
"Simulation mode is disabled in the product execution API so it cannot be recorded as real success."
|
|
159
|
+
)
|
|
160
|
+
return orchestrator
|
|
161
|
+
|
|
162
|
+
# ── roster + status ───────────────────────────────────────────────────
|
|
163
|
+
def _roster(self, runs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
164
|
+
"""Canonical role roster enriched with real run statistics."""
|
|
165
|
+
by_agent: Dict[str, Dict[str, Any]] = {}
|
|
166
|
+
for run in runs:
|
|
167
|
+
aid = str(run.get("agent_id") or "")
|
|
168
|
+
entry = by_agent.setdefault(aid, {"runs": 0, "last_status": None, "last_at": None})
|
|
169
|
+
entry["runs"] += 1
|
|
170
|
+
if entry["last_at"] is None: # runs are newest-first
|
|
171
|
+
entry["last_status"] = run.get("status")
|
|
172
|
+
entry["last_at"] = run.get("created_at") or run.get("completed_at")
|
|
173
|
+
|
|
174
|
+
roster: List[Dict[str, Any]] = []
|
|
175
|
+
order = list(CORE_PIPELINE) # planner, executor, reviewer first
|
|
176
|
+
ordered_roles = order + [r for r in AGENT_ROLES if r not in order]
|
|
177
|
+
for role in ordered_roles:
|
|
178
|
+
agent_id = ROLE_AGENT_IDS.get(role, f"agent:{role}")
|
|
179
|
+
stats = by_agent.get(agent_id, {"runs": 0, "last_status": None, "last_at": None})
|
|
180
|
+
handoffs = []
|
|
181
|
+
if role == "planner":
|
|
182
|
+
handoffs = [ROLE_AGENT_IDS["executor"]]
|
|
183
|
+
elif role == "executor":
|
|
184
|
+
handoffs = [ROLE_AGENT_IDS["reviewer"]]
|
|
185
|
+
roster.append({
|
|
186
|
+
"id": agent_id,
|
|
187
|
+
"name": role.capitalize(),
|
|
188
|
+
"role": ROLE_DESCRIPTIONS.get(role, ""),
|
|
189
|
+
"state": "available" if role != "release" else "idle",
|
|
190
|
+
"runs": stats["runs"],
|
|
191
|
+
"last_status": stats["last_status"],
|
|
192
|
+
"last_at": stats["last_at"],
|
|
193
|
+
"handoffs": handoffs,
|
|
194
|
+
})
|
|
195
|
+
return roster
|
|
196
|
+
|
|
197
|
+
def status(self, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
198
|
+
try:
|
|
199
|
+
listing = self._store.list_agents(workspace_id=scope)
|
|
200
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
201
|
+
listing = {"agents": [], "runs": [], "error": str(exc)}
|
|
202
|
+
runs = list(listing.get("runs") or [])
|
|
203
|
+
active = sum(1 for r in runs if str(r.get("status")) in _ACTIVE_STATUSES)
|
|
204
|
+
health = self.health()
|
|
205
|
+
orchestrator_status = (health.get("checks") or {}).get("orchestrator") or {}
|
|
206
|
+
ready = bool(health.get("ready"))
|
|
207
|
+
return {
|
|
208
|
+
"runtime": {
|
|
209
|
+
"ready": ready,
|
|
210
|
+
"version": MULTI_AGENT_VERSION,
|
|
211
|
+
"execution_mode": self._execution_mode(),
|
|
212
|
+
"mode": orchestrator_status.get("mode", "unknown"),
|
|
213
|
+
"unavailable_reason": None if ready else orchestrator_status.get("detail"),
|
|
214
|
+
"default_pipeline": list(CORE_PIPELINE),
|
|
215
|
+
"total_runs": len(runs),
|
|
216
|
+
"active_runs": active,
|
|
217
|
+
},
|
|
218
|
+
"health": health,
|
|
219
|
+
"roles": self.roles(),
|
|
220
|
+
"agents": self._roster(runs),
|
|
221
|
+
"runs": runs[:25],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# ── events / state ────────────────────────────────────────────────────
|
|
225
|
+
def list_runs(self, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
226
|
+
return self._store.list_agents(workspace_id=scope)
|
|
227
|
+
|
|
228
|
+
def get_run(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
229
|
+
return {"run": self._store.get_agent_run(run_id, workspace_id=scope)}
|
|
230
|
+
|
|
231
|
+
def replay(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
232
|
+
return {"replay": self._store.replay_agent_run(run_id, workspace_id=scope)}
|
|
233
|
+
|
|
234
|
+
def events(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
235
|
+
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
236
|
+
status = str(run.get("status") or "")
|
|
237
|
+
return {
|
|
238
|
+
"run_id": run_id,
|
|
239
|
+
"status": status,
|
|
240
|
+
"is_final": status in _TERMINAL_STATUSES or status not in _ACTIVE_STATUSES,
|
|
241
|
+
"current_role": run.get("current_role"),
|
|
242
|
+
"timeline": run.get("timeline") or [],
|
|
243
|
+
"handoffs": run.get("handoffs") or [],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# ── execution ─────────────────────────────────────────────────────────
|
|
247
|
+
def _fire_pre_run(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
goal: str,
|
|
251
|
+
roles: Optional[List[str]],
|
|
252
|
+
max_retries: int,
|
|
253
|
+
user_email: Optional[str],
|
|
254
|
+
scope: Optional[str],
|
|
255
|
+
) -> Optional[Dict[str, Any]]:
|
|
256
|
+
pre_dispatch: Optional[Dict[str, Any]] = None
|
|
257
|
+
if self._hooks is not None:
|
|
258
|
+
pre_dispatch = self._hooks.fire_hook(
|
|
259
|
+
"pre_run", "agent.run",
|
|
260
|
+
payload={"goal": goal, "roles": roles or None, "max_retries": max_retries},
|
|
261
|
+
user_email=user_email, workspace_id=scope,
|
|
262
|
+
)
|
|
263
|
+
if pre_dispatch.get("blocked"):
|
|
264
|
+
self._append_audit_event(
|
|
265
|
+
"multi_agent_run_blocked",
|
|
266
|
+
user_email=user_email,
|
|
267
|
+
reason=pre_dispatch.get("block_reason"),
|
|
268
|
+
)
|
|
269
|
+
raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
|
|
270
|
+
return pre_dispatch
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _result_patch(result: Any, goal: str) -> Dict[str, Any]:
|
|
274
|
+
return {
|
|
275
|
+
"agent_id": result.agent_id,
|
|
276
|
+
"status": result.status,
|
|
277
|
+
"input": goal,
|
|
278
|
+
"output_text": result.output,
|
|
279
|
+
"timeline": result.timeline,
|
|
280
|
+
"relationships": [ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
|
|
281
|
+
"handoffs": result.handoffs,
|
|
282
|
+
"context_packets": result.context_packets,
|
|
283
|
+
"plan": result.plan,
|
|
284
|
+
"plan_review": result.plan_review,
|
|
285
|
+
"review_history": result.review_history,
|
|
286
|
+
"retry_history": result.retry_history,
|
|
287
|
+
"memory_snapshots": result.memory_snapshots,
|
|
288
|
+
"mode": getattr(result, "mode", "simulation"),
|
|
289
|
+
"current_role": None,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
def _post_run_hooks(
|
|
293
|
+
self,
|
|
294
|
+
*,
|
|
295
|
+
run_id: Optional[str],
|
|
296
|
+
result: Any,
|
|
297
|
+
user_email: Optional[str],
|
|
298
|
+
scope: Optional[str],
|
|
299
|
+
status: Optional[str] = None,
|
|
300
|
+
) -> Optional[Dict[str, Any]]:
|
|
301
|
+
if self._hooks is None:
|
|
302
|
+
return None
|
|
303
|
+
return self._hooks.fire_hook(
|
|
304
|
+
"post_run", "agent.run",
|
|
305
|
+
payload={
|
|
306
|
+
"run_id": run_id,
|
|
307
|
+
"agent_id": result.agent_id,
|
|
308
|
+
"status": status or result.status,
|
|
309
|
+
"retries": result.retries,
|
|
310
|
+
},
|
|
311
|
+
user_email=user_email, workspace_id=scope,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def reserve_run(
|
|
315
|
+
self,
|
|
316
|
+
goal: str,
|
|
317
|
+
*,
|
|
318
|
+
user_email: Optional[str],
|
|
319
|
+
scope: Optional[str],
|
|
320
|
+
roles: Optional[List[str]] = None,
|
|
321
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
322
|
+
max_retries: int = 2,
|
|
323
|
+
) -> Dict[str, Any]:
|
|
324
|
+
"""Create the durable queued row used by the async executor."""
|
|
325
|
+
if not str(goal or "").strip():
|
|
326
|
+
raise ValueError("goal is required")
|
|
327
|
+
pre_dispatch = self._fire_pre_run(
|
|
328
|
+
goal=goal,
|
|
329
|
+
roles=roles,
|
|
330
|
+
max_retries=max_retries,
|
|
331
|
+
user_email=user_email,
|
|
332
|
+
scope=scope,
|
|
333
|
+
)
|
|
334
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
335
|
+
mode = getattr(orchestrator, "mode", "llm")
|
|
336
|
+
run = self._store.record_agent_run(
|
|
337
|
+
agent_id=ROLE_AGENT_IDS.get("executor", "agent:executor"),
|
|
338
|
+
status="queued",
|
|
339
|
+
input_text=goal,
|
|
340
|
+
output_text="",
|
|
341
|
+
timeline=[{"event": "agent_started", "status": "queued", "timestamp": _now()}],
|
|
342
|
+
relationships=[],
|
|
343
|
+
handoffs=[],
|
|
344
|
+
context_packets=[],
|
|
345
|
+
plan=[],
|
|
346
|
+
plan_review={},
|
|
347
|
+
review_history=[],
|
|
348
|
+
retry_history=[],
|
|
349
|
+
memory_snapshots=[],
|
|
350
|
+
user_email=user_email or None,
|
|
351
|
+
graph=None,
|
|
352
|
+
workspace_id=scope,
|
|
353
|
+
mode=mode,
|
|
354
|
+
)
|
|
355
|
+
run = self._store.update_agent_run(
|
|
356
|
+
run.get("id"),
|
|
357
|
+
workspace_id=scope,
|
|
358
|
+
execution_mode="async",
|
|
359
|
+
requested_roles=roles or None,
|
|
360
|
+
inputs=inputs or {},
|
|
361
|
+
max_retries=max_retries,
|
|
362
|
+
)
|
|
363
|
+
payload: Dict[str, Any] = {"run": run}
|
|
364
|
+
if pre_dispatch is not None:
|
|
365
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
366
|
+
return payload
|
|
367
|
+
|
|
368
|
+
def complete_reserved_run(
|
|
369
|
+
self,
|
|
370
|
+
run_id: str,
|
|
371
|
+
goal: str,
|
|
372
|
+
*,
|
|
373
|
+
user_email: Optional[str],
|
|
374
|
+
scope: Optional[str],
|
|
375
|
+
roles: Optional[List[str]] = None,
|
|
376
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
377
|
+
max_retries: int = 2,
|
|
378
|
+
pre_dispatch: Optional[Dict[str, Any]] = None,
|
|
379
|
+
cancel_requested: Optional[Callable[[], bool]] = None,
|
|
380
|
+
) -> Dict[str, Any]:
|
|
381
|
+
"""Execute orchestration and update an existing durable async row."""
|
|
382
|
+
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
383
|
+
base_timeline = list(run.get("timeline") or [])
|
|
384
|
+
self._store.update_agent_run(
|
|
385
|
+
run_id,
|
|
386
|
+
workspace_id=scope,
|
|
387
|
+
status="running",
|
|
388
|
+
current_role=(roles or list(CORE_PIPELINE))[0] if (roles or CORE_PIPELINE) else None,
|
|
389
|
+
started_at=run.get("started_at") or _now(),
|
|
390
|
+
)
|
|
391
|
+
try:
|
|
392
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
393
|
+
result = orchestrator.run(
|
|
394
|
+
goal,
|
|
395
|
+
user_email=user_email or None,
|
|
396
|
+
workspace_id=scope,
|
|
397
|
+
inputs=inputs or {},
|
|
398
|
+
roles=roles or None,
|
|
399
|
+
max_retries=max(0, min(int(max_retries or 0), self._max_retries_cap)),
|
|
400
|
+
)
|
|
401
|
+
except Exception as exc:
|
|
402
|
+
failed = self._store.update_agent_run(
|
|
403
|
+
run_id,
|
|
404
|
+
workspace_id=scope,
|
|
405
|
+
graph=self._workspace_graph(),
|
|
406
|
+
status="failed",
|
|
407
|
+
current_role=None,
|
|
408
|
+
error=str(exc),
|
|
409
|
+
output_text=str(exc),
|
|
410
|
+
timeline=base_timeline + [{
|
|
411
|
+
"event": "execution_failed",
|
|
412
|
+
"status": "failed",
|
|
413
|
+
"detail": str(exc),
|
|
414
|
+
"timestamp": _now(),
|
|
415
|
+
}],
|
|
416
|
+
)
|
|
417
|
+
self._append_audit_event("multi_agent_run", user_email=user_email, agent_id=failed.get("agent_id"), status="failed", retries=0)
|
|
418
|
+
return {"run": failed, "result": {"status": "failed", "error": str(exc)}}
|
|
419
|
+
|
|
420
|
+
patch = self._result_patch(result, goal)
|
|
421
|
+
patch["timeline"] = base_timeline + list(result.timeline or [])
|
|
422
|
+
if cancel_requested is not None and cancel_requested():
|
|
423
|
+
patch["status"] = "cancelled"
|
|
424
|
+
patch["current_role"] = None
|
|
425
|
+
patch["cancel_reason"] = "cancelled after the current synchronous step completed"
|
|
426
|
+
patch["cancelled_at"] = _now()
|
|
427
|
+
patch["timeline"] = patch["timeline"] + [{
|
|
428
|
+
"event": "execution_cancelled",
|
|
429
|
+
"status": "cancelled",
|
|
430
|
+
"reason": patch["cancel_reason"],
|
|
431
|
+
"timestamp": _now(),
|
|
432
|
+
}]
|
|
433
|
+
updated = self._store.update_agent_run(
|
|
434
|
+
run_id,
|
|
435
|
+
workspace_id=scope,
|
|
436
|
+
graph=self._workspace_graph(),
|
|
437
|
+
patch=patch,
|
|
438
|
+
)
|
|
439
|
+
self._append_audit_event(
|
|
440
|
+
"multi_agent_run",
|
|
441
|
+
user_email=user_email,
|
|
442
|
+
agent_id=result.agent_id,
|
|
443
|
+
status=updated.get("status") or result.status,
|
|
444
|
+
retries=result.retries,
|
|
445
|
+
)
|
|
446
|
+
post_dispatch = self._post_run_hooks(
|
|
447
|
+
run_id=run_id,
|
|
448
|
+
result=result,
|
|
449
|
+
user_email=user_email,
|
|
450
|
+
scope=scope,
|
|
451
|
+
status=updated.get("status") or result.status,
|
|
452
|
+
)
|
|
453
|
+
result_payload = result.as_dict()
|
|
454
|
+
if updated.get("status") == "cancelled":
|
|
455
|
+
result_payload = {"status": "cancelled", "reason": updated.get("cancel_reason"), "completed_result": result_payload}
|
|
456
|
+
payload = {"run": updated, "result": result_payload}
|
|
457
|
+
if pre_dispatch is not None:
|
|
458
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
459
|
+
if post_dispatch is not None:
|
|
460
|
+
payload["post_run_hooks"] = post_dispatch
|
|
461
|
+
return payload
|
|
462
|
+
|
|
463
|
+
def start(
|
|
464
|
+
self,
|
|
465
|
+
goal: str,
|
|
466
|
+
*,
|
|
467
|
+
user_email: Optional[str],
|
|
468
|
+
scope: Optional[str],
|
|
469
|
+
roles: Optional[List[str]] = None,
|
|
470
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
471
|
+
max_retries: int = 2,
|
|
472
|
+
) -> Dict[str, Any]:
|
|
473
|
+
if not str(goal or "").strip():
|
|
474
|
+
raise ValueError("goal is required")
|
|
475
|
+
|
|
476
|
+
# ── pre_run hooks ─────────────────────────────────────────────────
|
|
477
|
+
# A blocking pre_run hook (e.g. a policy gate) aborts the run before any
|
|
478
|
+
# orchestration happens. Hook failures never crash the run (fire_hook
|
|
479
|
+
# swallows them); only an explicit block stops it.
|
|
480
|
+
pre_dispatch: Optional[Dict[str, Any]] = None
|
|
481
|
+
if self._hooks is not None:
|
|
482
|
+
pre_dispatch = self._hooks.fire_hook(
|
|
483
|
+
"pre_run", "agent.run",
|
|
484
|
+
payload={"goal": goal, "roles": roles or None, "max_retries": max_retries},
|
|
485
|
+
user_email=user_email, workspace_id=scope,
|
|
486
|
+
)
|
|
487
|
+
if pre_dispatch.get("blocked"):
|
|
488
|
+
self._append_audit_event(
|
|
489
|
+
"multi_agent_run_blocked",
|
|
490
|
+
user_email=user_email,
|
|
491
|
+
reason=pre_dispatch.get("block_reason"),
|
|
492
|
+
)
|
|
493
|
+
raise PermissionError(pre_dispatch.get("block_reason") or "Agent run blocked by a pre_run hook.")
|
|
494
|
+
|
|
495
|
+
orchestrator = self._live_orchestrator(user_email, scope)
|
|
496
|
+
result = orchestrator.run(
|
|
497
|
+
goal,
|
|
498
|
+
user_email=user_email or None,
|
|
499
|
+
workspace_id=scope,
|
|
500
|
+
inputs=inputs or {},
|
|
501
|
+
roles=roles or None,
|
|
502
|
+
max_retries=max(0, min(int(max_retries or 0), self._max_retries_cap)),
|
|
503
|
+
)
|
|
504
|
+
run = self._store.record_agent_run(
|
|
505
|
+
agent_id=result.agent_id,
|
|
506
|
+
status=result.status,
|
|
507
|
+
input_text=goal,
|
|
508
|
+
output_text=result.output,
|
|
509
|
+
timeline=result.timeline,
|
|
510
|
+
relationships=[ROLE_AGENT_IDS.get(r, f"agent:{r}") for r in result.roles_run],
|
|
511
|
+
handoffs=result.handoffs,
|
|
512
|
+
context_packets=result.context_packets,
|
|
513
|
+
plan=result.plan,
|
|
514
|
+
plan_review=result.plan_review,
|
|
515
|
+
review_history=result.review_history,
|
|
516
|
+
retry_history=result.retry_history,
|
|
517
|
+
memory_snapshots=result.memory_snapshots,
|
|
518
|
+
user_email=user_email or None,
|
|
519
|
+
graph=self._workspace_graph(),
|
|
520
|
+
workspace_id=scope,
|
|
521
|
+
mode=getattr(result, "mode", "simulation"),
|
|
522
|
+
)
|
|
523
|
+
self._append_audit_event(
|
|
524
|
+
"multi_agent_run",
|
|
525
|
+
user_email=user_email,
|
|
526
|
+
agent_id=result.agent_id,
|
|
527
|
+
status=result.status,
|
|
528
|
+
retries=result.retries,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# ── post_run hooks ────────────────────────────────────────────────
|
|
532
|
+
post_dispatch: Optional[Dict[str, Any]] = None
|
|
533
|
+
if self._hooks is not None:
|
|
534
|
+
run_id = run.get("id") or run.get("run_id") if isinstance(run, dict) else None
|
|
535
|
+
post_dispatch = self._hooks.fire_hook(
|
|
536
|
+
"post_run", "agent.run",
|
|
537
|
+
payload={
|
|
538
|
+
"run_id": run_id,
|
|
539
|
+
"agent_id": result.agent_id,
|
|
540
|
+
"status": result.status,
|
|
541
|
+
"retries": result.retries,
|
|
542
|
+
},
|
|
543
|
+
user_email=user_email, workspace_id=scope,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
payload = {"run": run, "result": result.as_dict()}
|
|
547
|
+
if pre_dispatch is not None:
|
|
548
|
+
payload["pre_run_hooks"] = pre_dispatch
|
|
549
|
+
if post_dispatch is not None:
|
|
550
|
+
payload["post_run_hooks"] = post_dispatch
|
|
551
|
+
return payload
|
|
552
|
+
|
|
553
|
+
def stop(self, run_id: str, *, scope: Optional[str] = None) -> Dict[str, Any]:
|
|
554
|
+
"""Best-effort stop.
|
|
555
|
+
|
|
556
|
+
The default runtime executes synchronously, so by the time a run id
|
|
557
|
+
exists the run has already completed. Report that honestly rather than
|
|
558
|
+
pretending a cancellation occurred.
|
|
559
|
+
"""
|
|
560
|
+
if self._run_executor is not None:
|
|
561
|
+
return self._run_executor.cancel(run_id, kind="agent", scope=scope)
|
|
562
|
+
try:
|
|
563
|
+
run = self._store.get_agent_run(run_id, workspace_id=scope)
|
|
564
|
+
except FileNotFoundError:
|
|
565
|
+
return {"stopped": False, "reason": "run not found", "run_id": run_id}
|
|
566
|
+
status = str(run.get("status") or "")
|
|
567
|
+
if status in _ACTIVE_STATUSES:
|
|
568
|
+
return {"stopped": False, "reason": "asynchronous cancellation is not supported by the synchronous runtime", "run_id": run_id, "status": status}
|
|
569
|
+
return {"stopped": False, "reason": "run already finished", "run_id": run_id, "status": status}
|