ltcai 4.3.3 → 4.4.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 +21 -16
- package/docs/CHANGELOG.md +37 -0
- package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
- 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/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/multi_agent.py +6 -790
- package/latticeai/core/workflow_engine.py +6 -456
- package/latticeai/core/workspace_os.py +1 -1
- 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/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/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 +1 -1
package/lattice_brain/schema.py
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
"""Compatibility shim: implementation moved to lattice_brain.graph.schema.
|
|
2
|
+
|
|
3
|
+
This module aliases itself to the physical module so identity, singletons,
|
|
4
|
+
and monkeypatching behave as if the old flat path were the real module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .graph import schema as _impl
|
|
10
|
+
|
|
11
|
+
sys.modules[__name__] = _impl
|
package/lattice_brain/store.py
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
"""Compatibility shim: implementation moved to lattice_brain.graph.store.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This module aliases itself to the physical module so identity, singletons,
|
|
4
|
+
and monkeypatching behave as if the old flat path were the real module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .graph import store as _impl
|
|
10
|
+
|
|
11
|
+
sys.modules[__name__] = _impl
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Workflow engine — typed-node workflow definitions, validation, and a
|
|
2
|
+
deterministic execution interpreter with full run observability.
|
|
3
|
+
|
|
4
|
+
A workflow is a small directed graph of *nodes* starting from a ``trigger``.
|
|
5
|
+
Each node has a ``type`` (:data:`NODE_TYPES`), a ``config`` blob, and a ``next``
|
|
6
|
+
pointer (or a list of branches for ``condition`` nodes). The engine walks the
|
|
7
|
+
graph from the trigger, dispatching each node to an injected *runner* and
|
|
8
|
+
recording a step-by-step timeline so a run can be inspected, replayed, and
|
|
9
|
+
linked into the Workspace timeline / Knowledge Graph.
|
|
10
|
+
|
|
11
|
+
The engine is pure logic with injected runners, mirroring
|
|
12
|
+
:class:`latticeai.core.agent.AgentRuntime`:
|
|
13
|
+
|
|
14
|
+
* production wires runners that call the real tool registry, skill registry,
|
|
15
|
+
plugin registry, and multi-agent orchestrator;
|
|
16
|
+
* tests pass fakes and drive a full trigger→...→output run with no server,
|
|
17
|
+
no LLM, and no network.
|
|
18
|
+
|
|
19
|
+
Backward compatibility: legacy workflows persisted as a flat ``steps`` list
|
|
20
|
+
(pre-2.0) still validate and run — :func:`normalize_definition` lifts them into
|
|
21
|
+
a linear node chain so existing workflow history keeps working.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
WORKFLOW_ENGINE_VERSION = "2.2.0"
|
|
32
|
+
|
|
33
|
+
# The node vocabulary a workflow can be built from. ``trigger`` and ``output``
|
|
34
|
+
# are structural; the rest dispatch to an injected runner of the same family.
|
|
35
|
+
NODE_TYPES = (
|
|
36
|
+
"trigger",
|
|
37
|
+
"tool",
|
|
38
|
+
"skill",
|
|
39
|
+
"plugin",
|
|
40
|
+
"agent",
|
|
41
|
+
"condition",
|
|
42
|
+
"output",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Which runner family handles each executable node type.
|
|
46
|
+
_RUNNER_FOR = {
|
|
47
|
+
"tool": "tool",
|
|
48
|
+
"skill": "skill",
|
|
49
|
+
"plugin": "plugin",
|
|
50
|
+
"agent": "agent",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_MAX_STEPS = 100 # hard cap so a mis-wired ``next`` cycle can never hang a run.
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WorkflowError(Exception):
|
|
57
|
+
"""Raised for invalid workflow definitions."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _now() -> str:
|
|
61
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_definition(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
|
65
|
+
"""Return a node-based definition, lifting legacy ``steps`` lists if needed.
|
|
66
|
+
|
|
67
|
+
Never mutates the input. A legacy ``{"steps": [...]}`` workflow becomes a
|
|
68
|
+
linear ``trigger -> tool... -> output`` node chain so it validates and runs
|
|
69
|
+
under the v2.0 engine without rewriting stored history.
|
|
70
|
+
"""
|
|
71
|
+
nodes = workflow.get("nodes")
|
|
72
|
+
if isinstance(nodes, list) and nodes:
|
|
73
|
+
return {
|
|
74
|
+
"id": workflow.get("id"),
|
|
75
|
+
"name": workflow.get("name") or "Untitled workflow",
|
|
76
|
+
"nodes": nodes,
|
|
77
|
+
"metadata": workflow.get("metadata") or {},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
steps = workflow.get("steps") or []
|
|
81
|
+
lifted: List[Dict[str, Any]] = [{
|
|
82
|
+
"id": "trigger",
|
|
83
|
+
"type": "trigger",
|
|
84
|
+
"name": "Start",
|
|
85
|
+
"config": {"trigger": "manual"},
|
|
86
|
+
"next": "step-0" if steps else "output",
|
|
87
|
+
}]
|
|
88
|
+
for index, step in enumerate(steps):
|
|
89
|
+
action = str(step.get("action") or "tool") if isinstance(step, dict) else "tool"
|
|
90
|
+
nxt = f"step-{index + 1}" if index + 1 < len(steps) else "output"
|
|
91
|
+
lifted.append({
|
|
92
|
+
"id": f"step-{index}",
|
|
93
|
+
"type": "tool",
|
|
94
|
+
"name": action,
|
|
95
|
+
"config": {"tool": action, "args": step if isinstance(step, dict) else {"value": step}},
|
|
96
|
+
"next": nxt,
|
|
97
|
+
})
|
|
98
|
+
lifted.append({"id": "output", "type": "output", "name": "Output", "config": {}, "next": None})
|
|
99
|
+
return {
|
|
100
|
+
"id": workflow.get("id"),
|
|
101
|
+
"name": workflow.get("name") or "Untitled workflow",
|
|
102
|
+
"nodes": lifted,
|
|
103
|
+
"metadata": {**(workflow.get("metadata") or {}), "lifted_from_steps": True},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_definition(workflow: Dict[str, Any]) -> List[str]:
|
|
108
|
+
"""Return a list of validation errors ([] means valid)."""
|
|
109
|
+
errors: List[str] = []
|
|
110
|
+
definition = normalize_definition(workflow)
|
|
111
|
+
nodes = definition["nodes"]
|
|
112
|
+
if not isinstance(nodes, list) or not nodes:
|
|
113
|
+
return ["workflow has no nodes"]
|
|
114
|
+
|
|
115
|
+
ids = [node.get("id") for node in nodes]
|
|
116
|
+
if len(set(ids)) != len(ids):
|
|
117
|
+
errors.append("duplicate node ids")
|
|
118
|
+
id_set = {nid for nid in ids if nid}
|
|
119
|
+
|
|
120
|
+
triggers = [node for node in nodes if node.get("type") == "trigger"]
|
|
121
|
+
if not triggers:
|
|
122
|
+
errors.append("workflow must have a trigger node")
|
|
123
|
+
elif len(triggers) > 1:
|
|
124
|
+
errors.append("workflow must have exactly one trigger node")
|
|
125
|
+
|
|
126
|
+
for node in nodes:
|
|
127
|
+
nid = node.get("id")
|
|
128
|
+
ntype = node.get("type")
|
|
129
|
+
if not nid:
|
|
130
|
+
errors.append("node missing id")
|
|
131
|
+
if ntype not in NODE_TYPES:
|
|
132
|
+
errors.append(f"node '{nid}': unknown type '{ntype}'")
|
|
133
|
+
# Validate edges point at real nodes (None terminates a branch).
|
|
134
|
+
targets: List[Any] = []
|
|
135
|
+
if ntype == "condition":
|
|
136
|
+
branches = node.get("branches") or {}
|
|
137
|
+
if not isinstance(branches, dict) or not branches:
|
|
138
|
+
errors.append(f"condition node '{nid}' must define branches (e.g. true/false)")
|
|
139
|
+
else:
|
|
140
|
+
targets.extend(branches.values())
|
|
141
|
+
else:
|
|
142
|
+
targets.append(node.get("next"))
|
|
143
|
+
for target in targets:
|
|
144
|
+
if target is not None and target not in id_set:
|
|
145
|
+
errors.append(f"node '{nid}' points at unknown node '{target}'")
|
|
146
|
+
return errors
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _entry_node(nodes: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
150
|
+
for node in nodes:
|
|
151
|
+
if node.get("type") == "trigger":
|
|
152
|
+
return node
|
|
153
|
+
return nodes[0] if nodes else None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _evaluate_condition(config: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
|
157
|
+
"""Safe condition evaluation — NO eval. Compares a context value to a literal.
|
|
158
|
+
|
|
159
|
+
config: ``{"left": "<context key>", "op": "==|!=|>|<|>=|<=|contains|truthy",
|
|
160
|
+
"right": <literal>}``. Unknown keys / ops resolve to ``False`` so a
|
|
161
|
+
mis-configured condition fails closed onto the ``false`` branch.
|
|
162
|
+
"""
|
|
163
|
+
left_key = config.get("left")
|
|
164
|
+
op = str(config.get("op") or "truthy")
|
|
165
|
+
right = config.get("right")
|
|
166
|
+
left = context.get(left_key) if left_key in context else config.get("left_value")
|
|
167
|
+
try:
|
|
168
|
+
if op == "truthy":
|
|
169
|
+
return bool(left)
|
|
170
|
+
if op == "==":
|
|
171
|
+
return left == right
|
|
172
|
+
if op == "!=":
|
|
173
|
+
return left != right
|
|
174
|
+
if op == "contains":
|
|
175
|
+
return right in left # type: ignore[operator]
|
|
176
|
+
if op in (">", "<", ">=", "<="):
|
|
177
|
+
lf, rf = float(left), float(right) # type: ignore[arg-type]
|
|
178
|
+
return {">": lf > rf, "<": lf < rf, ">=": lf >= rf, "<=": lf <= rf}[op]
|
|
179
|
+
except Exception:
|
|
180
|
+
return False
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class WorkflowRun:
|
|
186
|
+
workflow_id: Optional[str]
|
|
187
|
+
name: str
|
|
188
|
+
status: str = "ok" # ok | failed | partial | awaiting_approval
|
|
189
|
+
timeline: List[Dict[str, Any]] = field(default_factory=list)
|
|
190
|
+
outputs: Dict[str, Any] = field(default_factory=dict)
|
|
191
|
+
started_at: str = field(default_factory=_now)
|
|
192
|
+
finished_at: Optional[str] = None
|
|
193
|
+
# Suspension cursor (status == awaiting_approval): the paused node, what
|
|
194
|
+
# it is waiting for, and a JSON-serializable context snapshot resume()
|
|
195
|
+
# re-enters with — completed nodes are never re-executed.
|
|
196
|
+
paused_node: Optional[str] = None
|
|
197
|
+
pending_approval: Optional[Dict[str, Any]] = None
|
|
198
|
+
paused_context: Optional[Dict[str, Any]] = None
|
|
199
|
+
|
|
200
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
201
|
+
return {
|
|
202
|
+
"workflow_id": self.workflow_id,
|
|
203
|
+
"name": self.name,
|
|
204
|
+
"status": self.status,
|
|
205
|
+
"timeline": self.timeline,
|
|
206
|
+
"outputs": self.outputs,
|
|
207
|
+
"started_at": self.started_at,
|
|
208
|
+
"finished_at": self.finished_at,
|
|
209
|
+
"step_count": len(self.timeline),
|
|
210
|
+
"paused_node": self.paused_node,
|
|
211
|
+
"pending_approval": self.pending_approval,
|
|
212
|
+
"paused_context": self.paused_context,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ApprovalRequired(Exception):
|
|
217
|
+
"""A node needs an explicit human decision before it may execute.
|
|
218
|
+
|
|
219
|
+
Raised by governed runners (e.g. a non-auto-approve tool). The engine
|
|
220
|
+
pauses the run into ``awaiting_approval`` with a serializable cursor —
|
|
221
|
+
it never records a fake success and never silently skips the node.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(self, message: str, *, tool: Optional[str] = None,
|
|
225
|
+
args: Optional[Dict[str, Any]] = None,
|
|
226
|
+
permission: Optional[Dict[str, Any]] = None):
|
|
227
|
+
super().__init__(message)
|
|
228
|
+
self.tool = tool
|
|
229
|
+
self.args = args or {}
|
|
230
|
+
self.permission = permission or {}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _json_safe(value: Any) -> Any:
|
|
234
|
+
"""Round-trip through JSON so paused context is durably serializable."""
|
|
235
|
+
import json as _json
|
|
236
|
+
|
|
237
|
+
return _json.loads(_json.dumps(value, ensure_ascii=False, default=str))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class WorkflowEngine:
|
|
241
|
+
"""Interprets a validated workflow definition over injected runners.
|
|
242
|
+
|
|
243
|
+
``runners`` maps a family ("tool" / "skill" / "plugin" / "agent") to a
|
|
244
|
+
callable ``runner(node, context) -> Any``. A missing runner records the
|
|
245
|
+
node as ``skipped`` with a reason rather than failing the whole run, so a
|
|
246
|
+
workflow that references a capability the host has not wired degrades
|
|
247
|
+
gracefully (and the gap is visible in the timeline).
|
|
248
|
+
|
|
249
|
+
Suspension model (v4): a runner raising :class:`ApprovalRequired` pauses
|
|
250
|
+
the run (status ``awaiting_approval``) with the node cursor and a
|
|
251
|
+
JSON-serializable context snapshot. :meth:`resume` re-enters at the
|
|
252
|
+
paused node — completed nodes are NEVER re-executed.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None, *, hooks: Any = None):
|
|
256
|
+
self.runners = runners or {}
|
|
257
|
+
# Optional lifecycle hooks registry. When present, ``run`` fires the
|
|
258
|
+
# ``workflow`` hooks at workflow start and end so automation registered
|
|
259
|
+
# against the workflow lifecycle actually executes.
|
|
260
|
+
self.hooks = hooks
|
|
261
|
+
|
|
262
|
+
def run(self, workflow: Dict[str, Any], *, inputs: Optional[Dict[str, Any]] = None) -> WorkflowRun:
|
|
263
|
+
definition = normalize_definition(workflow)
|
|
264
|
+
errors = validate_definition(definition)
|
|
265
|
+
run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
|
|
266
|
+
if self.hooks is not None:
|
|
267
|
+
self.hooks.fire_hook(
|
|
268
|
+
"pre_workflow", "workflow.start",
|
|
269
|
+
payload={"workflow_id": definition.get("id"), "name": definition.get("name"), "valid": not errors},
|
|
270
|
+
)
|
|
271
|
+
if errors:
|
|
272
|
+
run.status = "failed"
|
|
273
|
+
run.timeline.append({"node": None, "type": "validation", "status": "failed", "errors": errors, "timestamp": _now()})
|
|
274
|
+
run.finished_at = _now()
|
|
275
|
+
if self.hooks is not None:
|
|
276
|
+
self.hooks.fire_hook(
|
|
277
|
+
"post_workflow", "workflow.end",
|
|
278
|
+
payload={"workflow_id": definition.get("id"), "status": run.status},
|
|
279
|
+
)
|
|
280
|
+
return run
|
|
281
|
+
|
|
282
|
+
nodes = {node["id"]: node for node in definition["nodes"]}
|
|
283
|
+
context: Dict[str, Any] = {"inputs": inputs or {}, **(inputs or {})}
|
|
284
|
+
current = _entry_node(definition["nodes"])
|
|
285
|
+
return self._execute(definition, run, nodes, context, current)
|
|
286
|
+
|
|
287
|
+
def resume(
|
|
288
|
+
self,
|
|
289
|
+
workflow: Dict[str, Any],
|
|
290
|
+
*,
|
|
291
|
+
paused_node: str,
|
|
292
|
+
paused_context: Dict[str, Any],
|
|
293
|
+
approved: bool,
|
|
294
|
+
prior_timeline: Optional[List[Dict[str, Any]]] = None,
|
|
295
|
+
) -> WorkflowRun:
|
|
296
|
+
"""Re-enter a paused run at its cursor; completed nodes never re-run.
|
|
297
|
+
|
|
298
|
+
``approved=True`` marks the paused node as human-approved (its runner
|
|
299
|
+
sees the node id in ``context['__approved_nodes__']``); ``False``
|
|
300
|
+
records an explicit denial and fails the run honestly.
|
|
301
|
+
"""
|
|
302
|
+
definition = normalize_definition(workflow)
|
|
303
|
+
nodes = {node["id"]: node for node in definition["nodes"]}
|
|
304
|
+
node = nodes.get(paused_node)
|
|
305
|
+
run = WorkflowRun(workflow_id=definition.get("id"), name=definition.get("name") or "workflow")
|
|
306
|
+
if prior_timeline:
|
|
307
|
+
run.timeline.extend(prior_timeline)
|
|
308
|
+
if node is None:
|
|
309
|
+
run.status = "failed"
|
|
310
|
+
run.timeline.append({"node": paused_node, "type": "resume", "status": "failed",
|
|
311
|
+
"reason": "paused node no longer exists in the definition",
|
|
312
|
+
"timestamp": _now()})
|
|
313
|
+
run.finished_at = _now()
|
|
314
|
+
return run
|
|
315
|
+
context: Dict[str, Any] = dict(paused_context or {})
|
|
316
|
+
if not approved:
|
|
317
|
+
run.status = "failed"
|
|
318
|
+
run.timeline.append({"node": paused_node, "type": node.get("type"),
|
|
319
|
+
"name": node.get("name") or paused_node,
|
|
320
|
+
"status": "denied",
|
|
321
|
+
"reason": "approval denied by the user",
|
|
322
|
+
"timestamp": _now()})
|
|
323
|
+
run.finished_at = _now()
|
|
324
|
+
return run
|
|
325
|
+
approvals = set(context.get("__approved_nodes__") or [])
|
|
326
|
+
approvals.add(paused_node)
|
|
327
|
+
context["__approved_nodes__"] = sorted(approvals)
|
|
328
|
+
return self._execute(definition, run, nodes, context, node)
|
|
329
|
+
|
|
330
|
+
def _execute(
|
|
331
|
+
self,
|
|
332
|
+
definition: Dict[str, Any],
|
|
333
|
+
run: WorkflowRun,
|
|
334
|
+
nodes: Dict[str, Dict[str, Any]],
|
|
335
|
+
context: Dict[str, Any],
|
|
336
|
+
current: Optional[Dict[str, Any]],
|
|
337
|
+
) -> WorkflowRun:
|
|
338
|
+
steps = 0
|
|
339
|
+
had_error = False
|
|
340
|
+
had_skip = False
|
|
341
|
+
while current is not None and steps < _MAX_STEPS:
|
|
342
|
+
steps += 1
|
|
343
|
+
ntype = current.get("type")
|
|
344
|
+
nid = current.get("id")
|
|
345
|
+
entry: Dict[str, Any] = {
|
|
346
|
+
"node": nid,
|
|
347
|
+
"type": ntype,
|
|
348
|
+
"name": current.get("name") or nid,
|
|
349
|
+
"timestamp": _now(),
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if ntype == "trigger":
|
|
353
|
+
entry["status"] = "ok"
|
|
354
|
+
entry["trigger"] = (current.get("config") or {}).get("trigger", "manual")
|
|
355
|
+
run.timeline.append(entry)
|
|
356
|
+
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
if ntype == "output":
|
|
360
|
+
entry["status"] = "ok"
|
|
361
|
+
payload = (current.get("config") or {}).get("value")
|
|
362
|
+
entry["output"] = payload if payload is not None else context.get("last_output")
|
|
363
|
+
run.outputs[nid] = entry["output"]
|
|
364
|
+
run.timeline.append(entry)
|
|
365
|
+
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
if ntype == "condition":
|
|
369
|
+
result = _evaluate_condition(current.get("config") or {}, context)
|
|
370
|
+
entry["status"] = "ok"
|
|
371
|
+
entry["result"] = result
|
|
372
|
+
run.timeline.append(entry)
|
|
373
|
+
branches = current.get("branches") or {}
|
|
374
|
+
target = branches.get("true" if result else "false")
|
|
375
|
+
current = nodes.get(target) if target else None
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
# Executable node → dispatch to its runner family.
|
|
379
|
+
family = _RUNNER_FOR.get(ntype)
|
|
380
|
+
runner = self.runners.get(family) if family else None
|
|
381
|
+
if runner is None:
|
|
382
|
+
entry["status"] = "skipped"
|
|
383
|
+
entry["reason"] = f"no '{family}' runner configured"
|
|
384
|
+
had_skip = True
|
|
385
|
+
run.timeline.append(entry)
|
|
386
|
+
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
387
|
+
continue
|
|
388
|
+
try:
|
|
389
|
+
result = runner(node=current, context=context)
|
|
390
|
+
entry["status"] = "ok"
|
|
391
|
+
entry["result"] = result
|
|
392
|
+
context["last_output"] = result
|
|
393
|
+
context[nid] = result
|
|
394
|
+
except ApprovalRequired as pause:
|
|
395
|
+
# Suspend — never a fake success, never a silent skip.
|
|
396
|
+
entry["status"] = "awaiting_approval"
|
|
397
|
+
entry["pending"] = {
|
|
398
|
+
"tool": pause.tool, "args": pause.args,
|
|
399
|
+
"permission": pause.permission, "reason": str(pause),
|
|
400
|
+
}
|
|
401
|
+
run.timeline.append(entry)
|
|
402
|
+
run.status = "awaiting_approval"
|
|
403
|
+
run.paused_node = nid
|
|
404
|
+
run.pending_approval = entry["pending"]
|
|
405
|
+
try:
|
|
406
|
+
run.paused_context = _json_safe(context)
|
|
407
|
+
except Exception:
|
|
408
|
+
run.paused_context = {"inputs": context.get("inputs") or {}}
|
|
409
|
+
if self.hooks is not None:
|
|
410
|
+
self.hooks.fire_hook(
|
|
411
|
+
"post_workflow", "workflow.paused",
|
|
412
|
+
payload={"workflow_id": definition.get("id"),
|
|
413
|
+
"status": run.status, "node": nid},
|
|
414
|
+
)
|
|
415
|
+
return run
|
|
416
|
+
except Exception as exc:
|
|
417
|
+
entry["status"] = "error"
|
|
418
|
+
entry["reason"] = str(exc)
|
|
419
|
+
had_error = True
|
|
420
|
+
run.timeline.append(entry)
|
|
421
|
+
current = nodes.get(current.get("next")) if current.get("next") else None
|
|
422
|
+
|
|
423
|
+
if steps >= _MAX_STEPS:
|
|
424
|
+
run.timeline.append({"node": None, "type": "guard", "status": "error", "reason": f"exceeded {_MAX_STEPS} steps (cycle?)", "timestamp": _now()})
|
|
425
|
+
had_error = True
|
|
426
|
+
|
|
427
|
+
run.status = "failed" if had_error else ("partial" if had_skip else "ok")
|
|
428
|
+
run.finished_at = _now()
|
|
429
|
+
if self.hooks is not None:
|
|
430
|
+
self.hooks.fire_hook(
|
|
431
|
+
"post_workflow", "workflow.end",
|
|
432
|
+
payload={"workflow_id": definition.get("id"), "name": definition.get("name"),
|
|
433
|
+
"status": run.status, "steps": steps},
|
|
434
|
+
)
|
|
435
|
+
return run
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def export_workflow(workflow: Dict[str, Any]) -> Dict[str, Any]:
|
|
439
|
+
"""Portable JSON representation (definition only — no run history / scope)."""
|
|
440
|
+
definition = normalize_definition(workflow)
|
|
441
|
+
return {
|
|
442
|
+
"lattice_workflow_export": WORKFLOW_ENGINE_VERSION,
|
|
443
|
+
"name": definition.get("name"),
|
|
444
|
+
"nodes": definition.get("nodes"),
|
|
445
|
+
"metadata": {k: v for k, v in (definition.get("metadata") or {}).items() if k != "lifted_from_steps"},
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def import_workflow(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
450
|
+
"""Validate an exported workflow and return a definition ready to persist."""
|
|
451
|
+
if not isinstance(data, dict):
|
|
452
|
+
raise WorkflowError("import payload must be a JSON object")
|
|
453
|
+
definition = {
|
|
454
|
+
"name": data.get("name") or "Imported workflow",
|
|
455
|
+
"nodes": data.get("nodes") or [],
|
|
456
|
+
"metadata": {**(data.get("metadata") or {}), "imported": True},
|
|
457
|
+
}
|
|
458
|
+
errors = validate_definition(definition)
|
|
459
|
+
if errors:
|
|
460
|
+
raise WorkflowError("; ".join(errors))
|
|
461
|
+
return definition
|
|
@@ -1 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
"""Compatibility shim: implementation moved to lattice_brain.graph.write_master.
|
|
2
|
+
|
|
3
|
+
This module aliases itself to the physical module so identity, singletons,
|
|
4
|
+
and monkeypatching behave as if the old flat path were the real module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .graph import write_master as _impl
|
|
10
|
+
|
|
11
|
+
sys.modules[__name__] = _impl
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/agents.py
CHANGED
|
@@ -45,8 +45,8 @@ def create_agents_router(
|
|
|
45
45
|
agent_runtime: Any = None,
|
|
46
46
|
run_executor: Any = None,
|
|
47
47
|
) -> APIRouter:
|
|
48
|
-
from
|
|
49
|
-
from
|
|
48
|
+
from lattice_brain.runtime.multi_agent import AGENT_ROLES, ROLE_AGENT_IDS
|
|
49
|
+
from lattice_brain.runtime.agent_runtime import AgentRuntime, AgentRuntimeUnavailable
|
|
50
50
|
|
|
51
51
|
# Single AgentRuntime boundary: the router (and via it, the frontend) talks
|
|
52
52
|
# to this façade instead of reaching into the orchestrator/store directly.
|
package/latticeai/api/browser.py
CHANGED
|
@@ -24,7 +24,7 @@ from urllib.parse import urlparse
|
|
|
24
24
|
from fastapi import APIRouter, HTTPException, Request
|
|
25
25
|
from pydantic import BaseModel
|
|
26
26
|
|
|
27
|
-
from
|
|
27
|
+
from lattice_brain.ingestion import IngestionItem
|
|
28
28
|
|
|
29
29
|
MAX_TAB_BYTES = 4 * 1024 * 1024 # 4 MB per captured tab payload
|
|
30
30
|
MAX_URL_FETCH_BYTES = 4 * 1024 * 1024 # 4 MB cap on a fetched page
|
package/latticeai/api/chat.py
CHANGED
|
@@ -24,7 +24,7 @@ from PIL import Image
|
|
|
24
24
|
from latticeai.core.agent import AgentRunContext, AgentState
|
|
25
25
|
from latticeai.core.context_builder import format_sources_footnote, retrieve_context_for_generation
|
|
26
26
|
from latticeai.core.document_generator import DocumentGenerationSession, detect_document_intent
|
|
27
|
-
from
|
|
27
|
+
from lattice_brain.runtime.hooks import dispatch_tool
|
|
28
28
|
from latticeai.services.app_context import AppContext
|
|
29
29
|
from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
|
|
30
30
|
from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, local_read, network_status
|
|
@@ -11,7 +11,7 @@ from fastapi.responses import StreamingResponse
|
|
|
11
11
|
from pydantic import BaseModel
|
|
12
12
|
|
|
13
13
|
from latticeai.core.agent import extract_action as _extract_agent_action
|
|
14
|
-
from
|
|
14
|
+
from lattice_brain.runtime.hooks import dispatch_tool
|
|
15
15
|
from tools import (
|
|
16
16
|
ToolError,
|
|
17
17
|
computer_click,
|
package/latticeai/api/hooks.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Hooks platform API router (v3.2.0).
|
|
2
2
|
|
|
3
|
-
Exposes the lifecycle :class:`~
|
|
3
|
+
Exposes the lifecycle :class:`~lattice_brain.runtime.hooks.HooksRegistry` over HTTP so
|
|
4
4
|
the /app Hooks view can list, inspect, enable/disable, reorder, and register
|
|
5
5
|
hooks. Full paths live in the decorators (no ``prefix=``), matching the rest of
|
|
6
6
|
the API.
|
|
@@ -13,7 +13,7 @@ from typing import Callable, List, Optional
|
|
|
13
13
|
from fastapi import APIRouter, HTTPException, Request
|
|
14
14
|
from pydantic import BaseModel
|
|
15
15
|
|
|
16
|
-
from
|
|
16
|
+
from lattice_brain.runtime.hooks import HooksRegistry
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class HookToggleRequest(BaseModel):
|
package/latticeai/api/mcp.py
CHANGED
|
@@ -17,7 +17,7 @@ from datetime import datetime
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any, Callable, Dict, List, Optional
|
|
19
19
|
|
|
20
|
-
from
|
|
20
|
+
from lattice_brain.ingestion import IngestionItem
|
|
21
21
|
from fastapi import APIRouter, HTTPException, Request
|
|
22
22
|
from pydantic import BaseModel
|
|
23
23
|
|
package/latticeai/api/tools.py
CHANGED
|
@@ -15,7 +15,7 @@ from pydantic import BaseModel
|
|
|
15
15
|
|
|
16
16
|
from latticeai.api.computer_use import create_computer_use_router
|
|
17
17
|
from latticeai.api.local_files import create_local_files_router
|
|
18
|
-
from
|
|
18
|
+
from lattice_brain.runtime.hooks import dispatch_tool
|
|
19
19
|
from latticeai.api.mcp import create_mcp_router
|
|
20
20
|
from latticeai.api.permissions import create_permissions_router
|
|
21
21
|
from latticeai.services.upload_service import process_uploaded_document
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Workflow Designer API router (v2).
|
|
2
2
|
|
|
3
3
|
Create / edit / validate / execute / inspect / export / import workflows plus
|
|
4
|
-
run history, layered on :mod:`
|
|
4
|
+
run history, layered on :mod:`lattice_brain.workflow` and the existing
|
|
5
5
|
``WorkspaceOSStore`` workflow persistence (so pre-2.0 workflow history is
|
|
6
6
|
preserved). Paths are namespaced under ``/workflows`` to avoid colliding with
|
|
7
7
|
``/workspace/workflows``.
|
|
@@ -67,7 +67,7 @@ def create_workflow_designer_router(
|
|
|
67
67
|
run_executor: Any = None,
|
|
68
68
|
trigger_service: Any = None,
|
|
69
69
|
) -> APIRouter:
|
|
70
|
-
from
|
|
70
|
+
from lattice_brain.workflow import (
|
|
71
71
|
WorkflowEngine,
|
|
72
72
|
validate_definition,
|
|
73
73
|
export_workflow,
|
package/latticeai/app_factory.py
CHANGED
|
@@ -110,7 +110,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
110
110
|
from latticeai.services.chat_service import ChatService
|
|
111
111
|
from latticeai.services.search_service import SearchService
|
|
112
112
|
from latticeai.core.embedding_providers import resolve_embedder, resolve_embedding_profile
|
|
113
|
-
from
|
|
113
|
+
from lattice_brain.runtime.agent_runtime import AgentRuntime
|
|
114
114
|
from latticeai.services.model_runtime import (
|
|
115
115
|
CLOUD_VERIFY_TTL_SECONDS,
|
|
116
116
|
ENGINE_MODEL_CATALOG,
|
|
@@ -152,7 +152,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
152
152
|
from latticeai.api.garden import create_garden_router
|
|
153
153
|
from latticeai.api.setup import create_setup_router
|
|
154
154
|
from latticeai.api.hooks import create_hooks_router
|
|
155
|
-
from
|
|
155
|
+
from lattice_brain.runtime.hooks import HooksRegistry
|
|
156
156
|
from latticeai.core.builtin_hooks import register_builtin_hook_runners
|
|
157
157
|
from latticeai.core.product_hardening import build_product_hardening_status
|
|
158
158
|
from latticeai.api.agent_registry import create_agent_registry_router
|
|
@@ -161,7 +161,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
161
161
|
from latticeai.api.browser import create_browser_router
|
|
162
162
|
from latticeai.api.portability import create_portability_router
|
|
163
163
|
from latticeai.services.memory_service import MemoryService
|
|
164
|
-
from
|
|
164
|
+
from lattice_brain.ingestion import IngestionItem, IngestionPipeline
|
|
165
165
|
from lattice_brain import BrainCore, ConversationStore
|
|
166
166
|
from lattice_brain.storage import storage_from_env
|
|
167
167
|
from lattice_brain.context import ContextAssembler
|
|
@@ -169,7 +169,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
169
169
|
from lattice_brain.identity import DeviceIdentity
|
|
170
170
|
from lattice_brain.network import BrainNetwork
|
|
171
171
|
from latticeai.api.network import create_network_router
|
|
172
|
-
from
|
|
172
|
+
from lattice_brain.portability import KGPortabilityService
|
|
173
173
|
# The aliased names below look unused but are part of the legacy
|
|
174
174
|
# ``server_app`` attribute surface: every local is exported via
|
|
175
175
|
# ``dict(locals())`` and reached through ``server_app.__getattr__``
|