ltcai 7.3.0 → 7.5.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 +34 -30
- package/docs/CHANGELOG.md +50 -0
- package/docs/ROADMAP_RECOMMENDATIONS.md +31 -5
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/quality.py +31 -4
- package/lattice_brain/retrieval_benchmark_fixtures.py +92 -0
- package/lattice_brain/runtime/agent_runtime.py +20 -4
- package/lattice_brain/runtime/contracts.py +293 -4
- package/lattice_brain/runtime/multi_agent.py +5 -2
- package/lattice_brain/workflow.py +11 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +3 -3
- package/latticeai/api/realtime.py +3 -1
- package/latticeai/core/audit.py +15 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/realtime.py +3 -0
- package/latticeai/core/workspace_os.py +34 -1
- package/package.json +6 -3
- package/scripts/brain_quality_eval.py +55 -3
- package/src-tauri/Cargo.lock +626 -1089
- package/src-tauri/Cargo.toml +3 -6
- package/src-tauri/src/main.rs +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +1 -1
- package/static/app/assets/core-CwxXejkd.js.map +1 -1
|
@@ -4,19 +4,158 @@ These contracts are deliberately small and serializable. The single-agent
|
|
|
4
4
|
state machine and the multi-agent facade can evolve independently, but callers
|
|
5
5
|
should see the same run identity, mode, status, role, timeline and artifact
|
|
6
6
|
shape across both paths.
|
|
7
|
+
|
|
8
|
+
Contract family (7.4.0)
|
|
9
|
+
-----------------------
|
|
10
|
+
Four observability surfaces — agent runs, workflow runs, audit events, and
|
|
11
|
+
realtime events — now share one **contract family**, ``agent-run-contract/v1``.
|
|
12
|
+
Every record emitted by any of these surfaces carries the same envelope keys so
|
|
13
|
+
a single consumer (timeline UI, exporter, replay tooling) can treat them
|
|
14
|
+
uniformly:
|
|
15
|
+
|
|
16
|
+
* ``family`` — always :data:`CONTRACT_FAMILY` (``agent-run-contract/v1``).
|
|
17
|
+
* ``schema_version`` — the per-surface schema string (see ``*_SCHEMA`` below).
|
|
18
|
+
* ``kind`` — one of :data:`CONTRACT_KINDS`.
|
|
19
|
+
* ``id`` — the record identity (run id / event id), may be ``None``.
|
|
20
|
+
* ``status`` — a normalized lifecycle status string.
|
|
21
|
+
* ``timestamp`` — ISO-8601 second-precision emit/observe time.
|
|
22
|
+
|
|
23
|
+
Each surface keeps its own rich, surface-specific keys *in addition* to the
|
|
24
|
+
envelope — the envelope is purely additive, so existing consumers are never
|
|
25
|
+
broken. :func:`stamp_contract` is the single place that writes the envelope;
|
|
26
|
+
:func:`is_contract_member` validates membership for tests and importers.
|
|
7
27
|
"""
|
|
8
28
|
|
|
9
29
|
from __future__ import annotations
|
|
10
30
|
|
|
11
31
|
from dataclasses import dataclass, field
|
|
12
32
|
from datetime import datetime
|
|
13
|
-
from typing import Any, Dict, List, Optional
|
|
33
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
14
34
|
|
|
15
35
|
|
|
16
36
|
def runtime_timestamp() -> str:
|
|
17
37
|
return datetime.now().isoformat(timespec="seconds")
|
|
18
38
|
|
|
19
39
|
|
|
40
|
+
# ── Contract family identity ────────────────────────────────────────────────
|
|
41
|
+
# A single family string ties every observability surface together. Per-surface
|
|
42
|
+
# schema versions live *under* the family so each surface can rev independently
|
|
43
|
+
# without forking the family contract consumers depend on.
|
|
44
|
+
CONTRACT_FAMILY = "agent-run-contract/v1"
|
|
45
|
+
|
|
46
|
+
AGENT_RUN_SCHEMA = "agent-run-contract/v1"
|
|
47
|
+
WORKFLOW_RUN_SCHEMA = "workflow-run-contract/v1"
|
|
48
|
+
AUDIT_EVENT_SCHEMA = "audit-event-contract/v1"
|
|
49
|
+
REALTIME_EVENT_SCHEMA = "realtime-event-contract/v1"
|
|
50
|
+
|
|
51
|
+
CONTRACT_KINDS = ("agent_run", "workflow_run", "audit_event", "realtime_event")
|
|
52
|
+
|
|
53
|
+
# The envelope keys every family member is guaranteed to expose.
|
|
54
|
+
CONTRACT_ENVELOPE_KEYS = ("family", "schema_version", "kind", "id", "status", "timestamp")
|
|
55
|
+
CONTRACT_VIEW_KEYS = (
|
|
56
|
+
"family",
|
|
57
|
+
"schema_version",
|
|
58
|
+
"kind",
|
|
59
|
+
"id",
|
|
60
|
+
"run_id",
|
|
61
|
+
"agent_id",
|
|
62
|
+
"runtime",
|
|
63
|
+
"mode",
|
|
64
|
+
"status",
|
|
65
|
+
"goal",
|
|
66
|
+
"current_role",
|
|
67
|
+
"timestamp",
|
|
68
|
+
"is_terminal",
|
|
69
|
+
"blocking_reasons",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_SCHEMA_FOR_KIND = {
|
|
73
|
+
"agent_run": AGENT_RUN_SCHEMA,
|
|
74
|
+
"workflow_run": WORKFLOW_RUN_SCHEMA,
|
|
75
|
+
"audit_event": AUDIT_EVENT_SCHEMA,
|
|
76
|
+
"realtime_event": REALTIME_EVENT_SCHEMA,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def stamp_contract(
|
|
81
|
+
body: Dict[str, Any],
|
|
82
|
+
*,
|
|
83
|
+
kind: str,
|
|
84
|
+
identity: Optional[str],
|
|
85
|
+
status: str,
|
|
86
|
+
timestamp: Optional[str] = None,
|
|
87
|
+
) -> Dict[str, Any]:
|
|
88
|
+
"""Return ``body`` with the ``agent-run-contract/v1`` envelope merged in.
|
|
89
|
+
|
|
90
|
+
Additive and idempotent: existing keys in ``body`` win for everything except
|
|
91
|
+
the six envelope keys, which are authoritative. ``kind`` must be one of
|
|
92
|
+
:data:`CONTRACT_KINDS`; the schema version is derived from it so callers
|
|
93
|
+
cannot mismatch ``kind`` and ``schema_version``.
|
|
94
|
+
"""
|
|
95
|
+
if kind not in _SCHEMA_FOR_KIND:
|
|
96
|
+
raise ValueError(f"unknown contract kind: {kind!r}")
|
|
97
|
+
enveloped = dict(body)
|
|
98
|
+
enveloped["family"] = CONTRACT_FAMILY
|
|
99
|
+
enveloped["schema_version"] = _SCHEMA_FOR_KIND[kind]
|
|
100
|
+
enveloped["kind"] = kind
|
|
101
|
+
enveloped["id"] = identity
|
|
102
|
+
enveloped["status"] = status
|
|
103
|
+
enveloped["timestamp"] = timestamp or body.get("timestamp") or runtime_timestamp()
|
|
104
|
+
return enveloped
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_contract_member(record: Any) -> bool:
|
|
108
|
+
"""True if ``record`` carries a valid ``agent-run-contract/v1`` envelope."""
|
|
109
|
+
if not isinstance(record, dict):
|
|
110
|
+
return False
|
|
111
|
+
if record.get("family") != CONTRACT_FAMILY:
|
|
112
|
+
return False
|
|
113
|
+
kind = record.get("kind")
|
|
114
|
+
if kind not in _SCHEMA_FOR_KIND:
|
|
115
|
+
return False
|
|
116
|
+
if record.get("schema_version") != _SCHEMA_FOR_KIND[kind]:
|
|
117
|
+
return False
|
|
118
|
+
return all(key in record for key in CONTRACT_ENVELOPE_KEYS)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_contract(record: Any) -> Optional[Dict[str, Any]]:
|
|
122
|
+
"""Return the normalized family contract carried by ``record``.
|
|
123
|
+
|
|
124
|
+
Consumers should call this instead of branching on whether they received an
|
|
125
|
+
agent run, workflow run, audit row, or realtime event. The function accepts
|
|
126
|
+
either a raw contract dict or a surface record with a nested ``contract``.
|
|
127
|
+
"""
|
|
128
|
+
if is_contract_member(record):
|
|
129
|
+
return dict(record)
|
|
130
|
+
if isinstance(record, dict) and is_contract_member(record.get("contract")):
|
|
131
|
+
return dict(record["contract"])
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def require_contract(record: Any) -> Dict[str, Any]:
|
|
136
|
+
"""Return a valid family contract or raise a precise error."""
|
|
137
|
+
contract = extract_contract(record)
|
|
138
|
+
if contract is None:
|
|
139
|
+
raise ValueError("record is missing an agent-run-contract/v1 family contract")
|
|
140
|
+
return contract
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def contract_view(record: Any) -> Dict[str, Any]:
|
|
144
|
+
"""Return a compact, surface-agnostic view for API and UI consumers."""
|
|
145
|
+
contract = require_contract(record)
|
|
146
|
+
return {key: contract.get(key) for key in CONTRACT_VIEW_KEYS if key in contract}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def contract_views(records: Iterable[Any]) -> List[Dict[str, Any]]:
|
|
150
|
+
"""Return compact views for every valid contract in ``records``."""
|
|
151
|
+
views: List[Dict[str, Any]] = []
|
|
152
|
+
for record in records:
|
|
153
|
+
contract = extract_contract(record)
|
|
154
|
+
if contract is not None:
|
|
155
|
+
views.append({key: contract.get(key) for key in CONTRACT_VIEW_KEYS if key in contract})
|
|
156
|
+
return views
|
|
157
|
+
|
|
158
|
+
|
|
20
159
|
@dataclass(frozen=True)
|
|
21
160
|
class AgentRunContract:
|
|
22
161
|
run_id: Optional[str]
|
|
@@ -38,13 +177,11 @@ class AgentRunContract:
|
|
|
38
177
|
return self.status in {"ok", "retried_ok", "failed", "rejected", "cancelled", "interrupted", "partial", "done"}
|
|
39
178
|
|
|
40
179
|
def as_dict(self) -> Dict[str, Any]:
|
|
41
|
-
|
|
42
|
-
"schema_version": self.schema_version,
|
|
180
|
+
body = {
|
|
43
181
|
"run_id": self.run_id,
|
|
44
182
|
"agent_id": self.agent_id,
|
|
45
183
|
"runtime": self.runtime,
|
|
46
184
|
"mode": self.mode,
|
|
47
|
-
"status": self.status,
|
|
48
185
|
"goal": self.goal,
|
|
49
186
|
"roles": list(self.roles),
|
|
50
187
|
"current_role": self.current_role,
|
|
@@ -54,6 +191,12 @@ class AgentRunContract:
|
|
|
54
191
|
"blocking_reasons": list(self.blocking_reasons),
|
|
55
192
|
"is_terminal": self.is_terminal,
|
|
56
193
|
}
|
|
194
|
+
return stamp_contract(
|
|
195
|
+
body,
|
|
196
|
+
kind="agent_run",
|
|
197
|
+
identity=self.run_id,
|
|
198
|
+
status=self.status,
|
|
199
|
+
)
|
|
57
200
|
|
|
58
201
|
|
|
59
202
|
def multi_agent_contract(
|
|
@@ -80,6 +223,41 @@ def multi_agent_contract(
|
|
|
80
223
|
).as_dict()
|
|
81
224
|
|
|
82
225
|
|
|
226
|
+
def run_record_contract(run: Dict[str, Any], *, runtime: str = "multi_agent") -> Dict[str, Any]:
|
|
227
|
+
"""Build the family contract from a persisted agent run row."""
|
|
228
|
+
run = dict(run or {})
|
|
229
|
+
timeline = list(run.get("timeline") or [])
|
|
230
|
+
retry_history = list(run.get("retry_history") or [])
|
|
231
|
+
roles = list(run.get("roles_run") or run.get("requested_roles") or [])
|
|
232
|
+
if not roles:
|
|
233
|
+
roles = [
|
|
234
|
+
str(item.get("role"))
|
|
235
|
+
for item in timeline
|
|
236
|
+
if isinstance(item, dict) and item.get("role")
|
|
237
|
+
]
|
|
238
|
+
return AgentRunContract(
|
|
239
|
+
run_id=run.get("id") or run.get("run_id"),
|
|
240
|
+
agent_id=str(run.get("agent_id") or "agent:executor"),
|
|
241
|
+
runtime=runtime,
|
|
242
|
+
mode=str(run.get("mode") or "simulation"),
|
|
243
|
+
status=str(run.get("status") or "unknown"),
|
|
244
|
+
goal=str(run.get("input") or run.get("goal") or ""),
|
|
245
|
+
roles=roles,
|
|
246
|
+
current_role=run.get("current_role"),
|
|
247
|
+
retries=len(retry_history),
|
|
248
|
+
timeline=timeline,
|
|
249
|
+
artifacts=[
|
|
250
|
+
{
|
|
251
|
+
"type": "run_record",
|
|
252
|
+
"workspace_id": run.get("workspace_id"),
|
|
253
|
+
"graph_node_id": run.get("graph_node_id"),
|
|
254
|
+
"execution_mode": run.get("execution_mode"),
|
|
255
|
+
}
|
|
256
|
+
],
|
|
257
|
+
blocking_reasons=[str(run.get("error"))] if run.get("error") else [],
|
|
258
|
+
).as_dict()
|
|
259
|
+
|
|
260
|
+
|
|
83
261
|
def single_agent_contract(
|
|
84
262
|
*,
|
|
85
263
|
ctx: Any,
|
|
@@ -105,3 +283,114 @@ def single_agent_contract(
|
|
|
105
283
|
artifacts=[],
|
|
106
284
|
blocking_reasons=list(blocking_reasons or []),
|
|
107
285
|
).as_dict()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ── Sibling-surface contract builders ───────────────────────────────────────
|
|
289
|
+
# These wrap the other three observability surfaces into the same family so a
|
|
290
|
+
# consumer can iterate agent runs, workflow runs, audit events and realtime
|
|
291
|
+
# events through one envelope.
|
|
292
|
+
|
|
293
|
+
def workflow_run_contract(run: Any) -> Dict[str, Any]:
|
|
294
|
+
"""Wrap a workflow run dict (``WorkflowRun.as_dict()``) in the family envelope.
|
|
295
|
+
|
|
296
|
+
Accepts either a ``WorkflowRun`` instance or its serialized dict so the
|
|
297
|
+
engine can stamp without importing the dataclass here (avoids a cycle).
|
|
298
|
+
"""
|
|
299
|
+
body = run.as_dict() if hasattr(run, "as_dict") else dict(run or {})
|
|
300
|
+
run_id = body.get("id") or body.get("run_id") or body.get("workflow_id")
|
|
301
|
+
workflow_id = body.get("workflow_id")
|
|
302
|
+
contract_body = {
|
|
303
|
+
"run_id": run_id,
|
|
304
|
+
"agent_id": f"workflow:{workflow_id or body.get('name') or 'workflow'}",
|
|
305
|
+
"runtime": "workflow",
|
|
306
|
+
"mode": body.get("mode") or "live",
|
|
307
|
+
"goal": body.get("name") or "workflow",
|
|
308
|
+
"roles": ["workflow"],
|
|
309
|
+
"current_role": body.get("current_node") or body.get("paused_node"),
|
|
310
|
+
"retries": 0,
|
|
311
|
+
"timeline": list(body.get("timeline") or []),
|
|
312
|
+
"artifacts": [
|
|
313
|
+
{
|
|
314
|
+
"type": "workflow_outputs",
|
|
315
|
+
"workflow_id": workflow_id,
|
|
316
|
+
"outputs": body.get("outputs") or {},
|
|
317
|
+
"pause": body.get("pause") or body.get("pending_approval"),
|
|
318
|
+
}
|
|
319
|
+
],
|
|
320
|
+
"blocking_reasons": [str((body.get("outputs") or {}).get("error"))] if (body.get("outputs") or {}).get("error") else [],
|
|
321
|
+
"is_terminal": str(body.get("status") or "") in {"ok", "failed", "cancelled", "interrupted", "partial", "rejected"},
|
|
322
|
+
}
|
|
323
|
+
return stamp_contract(
|
|
324
|
+
contract_body,
|
|
325
|
+
kind="workflow_run",
|
|
326
|
+
identity=run_id,
|
|
327
|
+
status=str(body.get("status") or "unknown"),
|
|
328
|
+
timestamp=body.get("started_at") or body.get("created_at"),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def audit_event_contract(event: Dict[str, Any]) -> Dict[str, Any]:
|
|
333
|
+
"""Wrap a single audit-log event in the family envelope.
|
|
334
|
+
|
|
335
|
+
Audit events have no lifecycle ``status``; we expose the event type as the
|
|
336
|
+
status so the envelope stays uniform and filterable. Identity is a stable
|
|
337
|
+
``event_type@timestamp`` key (audit events are append-only, not addressable).
|
|
338
|
+
"""
|
|
339
|
+
body = dict(event or {})
|
|
340
|
+
event_type = str(body.get("event_type") or "event")
|
|
341
|
+
ts = body.get("timestamp")
|
|
342
|
+
identity = body.get("event_id") or (f"{event_type}@{ts}" if ts else event_type)
|
|
343
|
+
contract_body = {
|
|
344
|
+
"run_id": body.get("run_id"),
|
|
345
|
+
"agent_id": str(body.get("agent_id") or body.get("workflow_id") or f"audit:{event_type}"),
|
|
346
|
+
"runtime": "audit",
|
|
347
|
+
"mode": "event",
|
|
348
|
+
"goal": event_type,
|
|
349
|
+
"roles": [],
|
|
350
|
+
"current_role": None,
|
|
351
|
+
"retries": int(body.get("retries") or 0),
|
|
352
|
+
"timeline": [{"event": event_type, "timestamp": ts, "status": body.get("status") or event_type}],
|
|
353
|
+
"artifacts": [{"type": "audit_payload", "payload": body}],
|
|
354
|
+
"blocking_reasons": [],
|
|
355
|
+
"is_terminal": True,
|
|
356
|
+
}
|
|
357
|
+
return stamp_contract(
|
|
358
|
+
contract_body,
|
|
359
|
+
kind="audit_event",
|
|
360
|
+
identity=identity,
|
|
361
|
+
status=event_type,
|
|
362
|
+
timestamp=ts,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def realtime_event_contract(event: Dict[str, Any]) -> Dict[str, Any]:
|
|
367
|
+
"""Wrap a realtime bus event in the family envelope.
|
|
368
|
+
|
|
369
|
+
The bus already assigns a monotonic ``seq`` and ``received_at``; we reuse
|
|
370
|
+
those as identity and timestamp so the envelope is consistent with the
|
|
371
|
+
feed's own ordering guarantees.
|
|
372
|
+
"""
|
|
373
|
+
body = dict(event or {})
|
|
374
|
+
seq = body.get("seq")
|
|
375
|
+
payload = body.get("payload") if isinstance(body.get("payload"), dict) else {}
|
|
376
|
+
contract_body = {
|
|
377
|
+
"run_id": payload.get("run_id") or body.get("run_id"),
|
|
378
|
+
"agent_id": str(payload.get("agent_id") or payload.get("workflow_id") or f"realtime:{body.get('area') or 'workspace'}"),
|
|
379
|
+
"runtime": "realtime",
|
|
380
|
+
"mode": "event",
|
|
381
|
+
"goal": str(body.get("event_type") or "event"),
|
|
382
|
+
"roles": [],
|
|
383
|
+
"current_role": payload.get("current_role"),
|
|
384
|
+
"retries": int(payload.get("retries") or 0),
|
|
385
|
+
"timeline": [{"event": body.get("event_type") or "event", "timestamp": body.get("received_at"), "payload": payload}],
|
|
386
|
+
"artifacts": [{"type": "realtime_payload", "payload": payload}],
|
|
387
|
+
"blocking_reasons": [str(payload.get("error"))] if payload.get("error") else [],
|
|
388
|
+
"is_terminal": str(payload.get("status") or "") in {"ok", "failed", "cancelled", "interrupted", "partial", "rejected"},
|
|
389
|
+
}
|
|
390
|
+
return stamp_contract(
|
|
391
|
+
contract_body,
|
|
392
|
+
kind="realtime_event",
|
|
393
|
+
identity=f"rt:{seq}" if seq is not None else None,
|
|
394
|
+
status=str(body.get("event_type") or "event"),
|
|
395
|
+
timestamp=body.get("received_at"),
|
|
396
|
+
)
|
|
@@ -21,7 +21,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
21
21
|
from .contracts import multi_agent_contract
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
MULTI_AGENT_VERSION = "7.
|
|
24
|
+
MULTI_AGENT_VERSION = "7.5.0"
|
|
25
25
|
|
|
26
26
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
27
27
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -286,6 +286,7 @@ class AgentRunResult:
|
|
|
286
286
|
retry_history: List[Dict[str, Any]] = field(default_factory=list)
|
|
287
287
|
plan_review: Dict[str, Any] = field(default_factory=dict)
|
|
288
288
|
memory_snapshots: List[Dict[str, Any]] = field(default_factory=list)
|
|
289
|
+
goal: str = ""
|
|
289
290
|
# "simulation" = deterministic LLM-free runner; "llm" = model-driven (v4 runtime).
|
|
290
291
|
mode: str = "simulation"
|
|
291
292
|
|
|
@@ -306,8 +307,9 @@ class AgentRunResult:
|
|
|
306
307
|
"retry_history": self.retry_history,
|
|
307
308
|
"plan_review": self.plan_review,
|
|
308
309
|
"memory_snapshots": self.memory_snapshots,
|
|
310
|
+
"goal": self.goal,
|
|
309
311
|
}
|
|
310
|
-
payload["contract"] = multi_agent_contract(result=self, goal=self.output)
|
|
312
|
+
payload["contract"] = multi_agent_contract(result=self, goal=self.goal or self.output)
|
|
311
313
|
return payload
|
|
312
314
|
|
|
313
315
|
|
|
@@ -800,5 +802,6 @@ class MultiAgentOrchestrator:
|
|
|
800
802
|
retry_history=ctx.retry_history,
|
|
801
803
|
plan_review=ctx.plan_review,
|
|
802
804
|
memory_snapshots=ctx.memory_snapshots,
|
|
805
|
+
goal=ctx.goal,
|
|
803
806
|
mode=self.mode,
|
|
804
807
|
)
|
|
@@ -27,6 +27,8 @@ from dataclasses import dataclass, field
|
|
|
27
27
|
from datetime import datetime
|
|
28
28
|
from typing import Any, Callable, Dict, List, Optional
|
|
29
29
|
|
|
30
|
+
from lattice_brain.runtime.contracts import workflow_run_contract
|
|
31
|
+
|
|
30
32
|
|
|
31
33
|
WORKFLOW_ENGINE_VERSION = "2.2.0"
|
|
32
34
|
|
|
@@ -198,7 +200,13 @@ class WorkflowRun:
|
|
|
198
200
|
paused_context: Optional[Dict[str, Any]] = None
|
|
199
201
|
|
|
200
202
|
def as_dict(self) -> Dict[str, Any]:
|
|
201
|
-
|
|
203
|
+
# Native workflow-run shape is preserved unchanged for existing readers.
|
|
204
|
+
# A normalized ``agent-run-contract/v1`` projection is attached under
|
|
205
|
+
# ``contract`` so workflow runs sit in the same observability family as
|
|
206
|
+
# agent runs, audit events and realtime events (mirrors the audit-log
|
|
207
|
+
# and realtime-bus convention). The projection is built from the native
|
|
208
|
+
# dict — never from ``self`` — so it cannot recurse back into as_dict().
|
|
209
|
+
body = {
|
|
202
210
|
"workflow_id": self.workflow_id,
|
|
203
211
|
"name": self.name,
|
|
204
212
|
"status": self.status,
|
|
@@ -211,6 +219,8 @@ class WorkflowRun:
|
|
|
211
219
|
"pending_approval": self.pending_approval,
|
|
212
220
|
"paused_context": self.paused_context,
|
|
213
221
|
}
|
|
222
|
+
body["contract"] = workflow_run_contract(body)
|
|
223
|
+
return body
|
|
214
224
|
|
|
215
225
|
|
|
216
226
|
class ApprovalRequired(Exception):
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/agents.py
CHANGED
|
@@ -111,7 +111,7 @@ def create_agents_router(
|
|
|
111
111
|
async def agent_runs(request: Request):
|
|
112
112
|
require_user(request)
|
|
113
113
|
scope = gate_read(request)
|
|
114
|
-
return
|
|
114
|
+
return runtime.list_runs(scope=scope)
|
|
115
115
|
|
|
116
116
|
@router.get("/agents/api/handoffs")
|
|
117
117
|
async def agent_handoffs(request: Request, run_id: str = ""):
|
|
@@ -124,7 +124,7 @@ def create_agents_router(
|
|
|
124
124
|
require_user(request)
|
|
125
125
|
scope = gate_read(request)
|
|
126
126
|
try:
|
|
127
|
-
return
|
|
127
|
+
return runtime.get_run(run_id, scope=scope)
|
|
128
128
|
except FileNotFoundError as exc:
|
|
129
129
|
raise HTTPException(status_code=404, detail=f"Agent run not found: {run_id}") from exc
|
|
130
130
|
|
|
@@ -133,7 +133,7 @@ def create_agents_router(
|
|
|
133
133
|
require_user(request)
|
|
134
134
|
scope = gate_read(request)
|
|
135
135
|
try:
|
|
136
|
-
return
|
|
136
|
+
return runtime.replay(run_id, scope=scope)
|
|
137
137
|
except FileNotFoundError as exc:
|
|
138
138
|
raise HTTPException(status_code=404, detail=f"Agent run not found: {run_id}") from exc
|
|
139
139
|
|
|
@@ -17,6 +17,7 @@ from fastapi.responses import StreamingResponse
|
|
|
17
17
|
from pydantic import BaseModel
|
|
18
18
|
|
|
19
19
|
from latticeai.api.ui_redirects import app_redirect
|
|
20
|
+
from lattice_brain.runtime.contracts import contract_views
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class PresenceRequest(BaseModel):
|
|
@@ -63,7 +64,8 @@ def create_realtime_router(
|
|
|
63
64
|
async def realtime_feed(request: Request, limit: int = 50):
|
|
64
65
|
user = require_user(request)
|
|
65
66
|
scope = allowed_scopes(user or None)
|
|
66
|
-
|
|
67
|
+
events = bus.recent(limit=limit, workspace_scope=scope)
|
|
68
|
+
return {"events": events, "contracts": contract_views(events), "stats": bus.stats()}
|
|
67
69
|
|
|
68
70
|
@router.get("/realtime/presence")
|
|
69
71
|
async def realtime_presence(request: Request):
|
package/latticeai/core/audit.py
CHANGED
|
@@ -5,9 +5,12 @@ import logging
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
import threading
|
|
8
|
+
import hashlib
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any, Callable, Dict, List, Optional
|
|
10
11
|
|
|
12
|
+
from lattice_brain.runtime.contracts import audit_event_contract
|
|
13
|
+
|
|
11
14
|
from . import timezones
|
|
12
15
|
from .security import redact_secrets
|
|
13
16
|
|
|
@@ -42,12 +45,21 @@ def get_audit_log(audit_file: Path) -> List[Dict]:
|
|
|
42
45
|
def append_audit_event(audit_file: Path, event_type: str, **payload) -> None:
|
|
43
46
|
try:
|
|
44
47
|
safe_payload = redact_secrets(payload)
|
|
48
|
+
timestamp = timezones.now_iso()
|
|
49
|
+
event_hash = hashlib.sha256(
|
|
50
|
+
json.dumps([event_type, timestamp, safe_payload], ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
|
|
51
|
+
).hexdigest()[:24]
|
|
45
52
|
event = {
|
|
53
|
+
"event_id": f"audit-{event_hash}",
|
|
46
54
|
"event_type": event_type,
|
|
47
55
|
# item 7: 대시보드 "오늘" 계산과 동일한 시간대 기준으로 기록한다.
|
|
48
|
-
"timestamp":
|
|
56
|
+
"timestamp": timestamp,
|
|
49
57
|
**safe_payload,
|
|
50
58
|
}
|
|
59
|
+
# Stamp the agent-run-contract/v1 family envelope so audit rows are
|
|
60
|
+
# uniform with agent/workflow/realtime records. Additive — existing
|
|
61
|
+
# readers ignore the extra keys; new consumers can filter by family.
|
|
62
|
+
event["contract"] = audit_event_contract(event)
|
|
51
63
|
with _history_lock:
|
|
52
64
|
events = get_audit_log(audit_file)
|
|
53
65
|
events.append(event)
|
|
@@ -236,6 +248,8 @@ def build_admin_audit_report(
|
|
|
236
248
|
|
|
237
249
|
def _public_audit_event(event: Dict) -> Dict:
|
|
238
250
|
allowed = {
|
|
251
|
+
# Stable event id + nested agent-run-contract/v1 family projection.
|
|
252
|
+
"event_id", "contract",
|
|
239
253
|
"event_type", "timestamp", "role", "user_email", "user_nickname", "source",
|
|
240
254
|
"conversation_id", "workspace_id", "command", "scope", "target_email", "filename", "mime_type",
|
|
241
255
|
"ext", "bytes", "extracted_chars", "graph_node", "keep_last", "removed", "kept",
|
|
@@ -32,6 +32,8 @@ import threading
|
|
|
32
32
|
from datetime import datetime
|
|
33
33
|
from typing import Any, AsyncIterator, Dict, List, Optional, Set
|
|
34
34
|
|
|
35
|
+
from lattice_brain.runtime.contracts import realtime_event_contract
|
|
36
|
+
|
|
35
37
|
|
|
36
38
|
REALTIME_VERSION = "2.2.0"
|
|
37
39
|
_FEED_LIMIT = 200
|
|
@@ -99,6 +101,7 @@ class RealtimeBus:
|
|
|
99
101
|
"payload": event.get("payload", {}),
|
|
100
102
|
**{k: v for k, v in event.items() if k not in {"area", "event_type", "workspace_id", "payload"}},
|
|
101
103
|
}
|
|
104
|
+
enriched["contract"] = realtime_event_contract(enriched)
|
|
102
105
|
self._feed.append(enriched)
|
|
103
106
|
if len(self._feed) > _FEED_LIMIT:
|
|
104
107
|
self._feed = self._feed[-_FEED_LIMIT:]
|
|
@@ -18,8 +18,10 @@ from datetime import datetime
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
|
+
from lattice_brain.runtime.contracts import realtime_event_contract, run_record_contract, workflow_run_contract
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
|
|
24
|
+
WORKSPACE_OS_VERSION = "7.5.0"
|
|
23
25
|
|
|
24
26
|
# Workspace types separate single-user Personal workspaces from shared
|
|
25
27
|
# Organization workspaces. Both keep the same local-first JSON store; the type
|
|
@@ -526,6 +528,7 @@ class WorkspaceOSStore:
|
|
|
526
528
|
"workspace_id": self._resolve_scope(workspace_id, state),
|
|
527
529
|
"payload": payload,
|
|
528
530
|
}
|
|
531
|
+
event["contract"] = realtime_event_contract({"seq": event["id"], "received_at": event["timestamp"], **event})
|
|
529
532
|
state.setdefault("timeline", []).append(event)
|
|
530
533
|
self.save_state(state)
|
|
531
534
|
if self.event_sink is not None:
|
|
@@ -1565,6 +1568,13 @@ class WorkspaceOSStore:
|
|
|
1565
1568
|
if status == "failed":
|
|
1566
1569
|
self._emit_execution_event(area="agent", event_type="execution_failed", payload={"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
|
|
1567
1570
|
self.record_timeline_event("agent", "agent_run", {"run_id": run["id"], "agent_id": agent_id, "status": status}, workspace_id=resolved_workspace)
|
|
1571
|
+
run["contract"] = run_record_contract(run)
|
|
1572
|
+
state = self.load_state()
|
|
1573
|
+
for item in _listify(state.get("agent_runs")):
|
|
1574
|
+
if item.get("id") == run["id"]:
|
|
1575
|
+
item["contract"] = run["contract"]
|
|
1576
|
+
break
|
|
1577
|
+
self.save_state(state)
|
|
1568
1578
|
return run
|
|
1569
1579
|
|
|
1570
1580
|
def update_agent_run(
|
|
@@ -1636,6 +1646,13 @@ class WorkspaceOSStore:
|
|
|
1636
1646
|
run["graph_error"] = str(exc)
|
|
1637
1647
|
|
|
1638
1648
|
self.save_state(state)
|
|
1649
|
+
run["contract"] = run_record_contract(run)
|
|
1650
|
+
state = self.load_state()
|
|
1651
|
+
for item in _listify(state.get("agent_runs")):
|
|
1652
|
+
if item.get("id") == run_id:
|
|
1653
|
+
item["contract"] = run["contract"]
|
|
1654
|
+
break
|
|
1655
|
+
self.save_state(state)
|
|
1639
1656
|
|
|
1640
1657
|
timeline = run.get("timeline") or []
|
|
1641
1658
|
if len(timeline) > old_timeline_len:
|
|
@@ -1774,6 +1791,13 @@ class WorkspaceOSStore:
|
|
|
1774
1791
|
elif status in {"ok", "partial"}:
|
|
1775
1792
|
self._emit_execution_event(area="workflow", event_type="workflow_completed", payload={"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1776
1793
|
self.record_timeline_event("workflow", "workflow_run", {"run_id": run["id"], "workflow_id": workflow_id, "status": status}, workspace_id=resolved_workspace)
|
|
1794
|
+
run["contract"] = workflow_run_contract(run)
|
|
1795
|
+
state = self.load_state()
|
|
1796
|
+
for item in _listify(state.get("workflow_runs")):
|
|
1797
|
+
if item.get("id") == run["id"]:
|
|
1798
|
+
item["contract"] = run["contract"]
|
|
1799
|
+
break
|
|
1800
|
+
self.save_state(state)
|
|
1777
1801
|
return run
|
|
1778
1802
|
|
|
1779
1803
|
def update_workflow_run(
|
|
@@ -1835,6 +1859,13 @@ class WorkspaceOSStore:
|
|
|
1835
1859
|
run["graph_error"] = str(exc)
|
|
1836
1860
|
|
|
1837
1861
|
self.save_state(state)
|
|
1862
|
+
run["contract"] = workflow_run_contract(run)
|
|
1863
|
+
state = self.load_state()
|
|
1864
|
+
for item in _listify(state.get("workflow_runs")):
|
|
1865
|
+
if item.get("id") == run_id:
|
|
1866
|
+
item["contract"] = run["contract"]
|
|
1867
|
+
break
|
|
1868
|
+
self.save_state(state)
|
|
1838
1869
|
|
|
1839
1870
|
timeline = run.get("timeline") or []
|
|
1840
1871
|
if len(timeline) > old_timeline_len:
|
|
@@ -2053,6 +2084,7 @@ class WorkspaceOSStore:
|
|
|
2053
2084
|
"run_id": run_id,
|
|
2054
2085
|
"status": run.get("status"),
|
|
2055
2086
|
"workspace_id": self._record_workspace(run),
|
|
2087
|
+
"contract": run.get("contract") or run_record_contract(run),
|
|
2056
2088
|
"replayable": True,
|
|
2057
2089
|
"frames": self._replay_frames(run, kind="agent"),
|
|
2058
2090
|
"handoffs": run.get("handoffs") or [],
|
|
@@ -2068,6 +2100,7 @@ class WorkspaceOSStore:
|
|
|
2068
2100
|
"run_id": run_id,
|
|
2069
2101
|
"status": run.get("status"),
|
|
2070
2102
|
"workspace_id": self._record_workspace(run),
|
|
2103
|
+
"contract": run.get("contract") or workflow_run_contract(run),
|
|
2071
2104
|
"replayable": True,
|
|
2072
2105
|
"frames": self._replay_frames(run, kind="workflow"),
|
|
2073
2106
|
"outputs": run.get("outputs") or {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.5.0",
|
|
4
4
|
"description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
@@ -115,8 +115,8 @@
|
|
|
115
115
|
"@playwright/test": "^1.60.0",
|
|
116
116
|
"@tailwindcss/postcss": "^4.3.0",
|
|
117
117
|
"@tanstack/react-query": "^5.101.0",
|
|
118
|
-
"@tauri-apps/api": "^2.
|
|
119
|
-
"@tauri-apps/cli": "^2.
|
|
118
|
+
"@tauri-apps/api": "^2.11.1",
|
|
119
|
+
"@tauri-apps/cli": "^2.11.3",
|
|
120
120
|
"@types/cytoscape": "^3.21.9",
|
|
121
121
|
"@types/react": "^19.2.17",
|
|
122
122
|
"@types/react-dom": "^19.2.3",
|
|
@@ -138,5 +138,8 @@
|
|
|
138
138
|
"typescript": "^5.9.3",
|
|
139
139
|
"vite": "^8.0.16",
|
|
140
140
|
"zustand": "^5.0.14"
|
|
141
|
+
},
|
|
142
|
+
"overrides": {
|
|
143
|
+
"js-yaml": "^4.2.0"
|
|
141
144
|
}
|
|
142
145
|
}
|