ltcai 3.5.0 → 4.0.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.
Files changed (181) hide show
  1. package/README.md +73 -35
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  6. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  8. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  9. package/docs/architecture.md +13 -12
  10. package/docs/kg-schema.md +102 -53
  11. package/docs/privacy.md +18 -2
  12. package/docs/security-model.md +17 -0
  13. package/kg_schema.py +139 -10
  14. package/knowledge_graph.py +874 -26
  15. package/knowledge_graph_api.py +11 -127
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/admin.py +1 -1
  18. package/latticeai/api/agents.py +7 -1
  19. package/latticeai/api/auth.py +27 -4
  20. package/latticeai/api/browser.py +217 -0
  21. package/latticeai/api/chat.py +112 -76
  22. package/latticeai/api/health.py +1 -1
  23. package/latticeai/api/hooks.py +1 -1
  24. package/latticeai/api/knowledge_graph.py +146 -0
  25. package/latticeai/api/local_files.py +1 -1
  26. package/latticeai/api/mcp.py +23 -11
  27. package/latticeai/api/memory.py +1 -1
  28. package/latticeai/api/models.py +1 -1
  29. package/latticeai/api/network.py +81 -0
  30. package/latticeai/api/portability.py +93 -0
  31. package/latticeai/api/realtime.py +1 -1
  32. package/latticeai/api/search.py +26 -2
  33. package/latticeai/api/security_dashboard.py +2 -3
  34. package/latticeai/api/setup.py +2 -2
  35. package/latticeai/api/static_routes.py +2 -4
  36. package/latticeai/api/tools.py +3 -0
  37. package/latticeai/api/workflow_designer.py +46 -0
  38. package/latticeai/api/workspace.py +71 -49
  39. package/latticeai/app_factory.py +1710 -0
  40. package/latticeai/brain/__init__.py +18 -0
  41. package/latticeai/brain/context.py +213 -0
  42. package/latticeai/brain/conversations.py +236 -0
  43. package/latticeai/brain/identity.py +175 -0
  44. package/latticeai/brain/memory.py +102 -0
  45. package/latticeai/brain/network.py +205 -0
  46. package/latticeai/core/agent.py +31 -7
  47. package/latticeai/core/audit.py +0 -7
  48. package/latticeai/core/config.py +1 -1
  49. package/latticeai/core/context_builder.py +1 -2
  50. package/latticeai/core/enterprise.py +1 -1
  51. package/latticeai/core/graph_curator.py +2 -2
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/mcp_registry.py +791 -0
  54. package/latticeai/core/model_compat.py +1 -1
  55. package/latticeai/core/model_resolution.py +0 -1
  56. package/latticeai/core/multi_agent.py +238 -4
  57. package/latticeai/core/security.py +1 -1
  58. package/latticeai/core/sessions.py +37 -7
  59. package/latticeai/core/workflow_engine.py +114 -2
  60. package/latticeai/core/workspace_os.py +58 -10
  61. package/latticeai/models/__init__.py +7 -0
  62. package/latticeai/models/router.py +779 -0
  63. package/latticeai/server_app.py +29 -1504
  64. package/latticeai/services/agent_runtime.py +1 -0
  65. package/latticeai/services/app_context.py +75 -14
  66. package/latticeai/services/ingestion.py +318 -0
  67. package/latticeai/services/kg_portability.py +207 -0
  68. package/latticeai/services/memory_service.py +39 -11
  69. package/latticeai/services/model_runtime.py +2 -5
  70. package/latticeai/services/platform_runtime.py +100 -23
  71. package/latticeai/services/search_service.py +17 -8
  72. package/latticeai/services/tool_dispatch.py +12 -2
  73. package/latticeai/services/triggers.py +241 -0
  74. package/latticeai/services/upload_service.py +37 -12
  75. package/latticeai/services/workspace_service.py +31 -0
  76. package/llm_router.py +29 -772
  77. package/ltcai_cli.py +1 -2
  78. package/mcp_registry.py +25 -788
  79. package/p_reinforce.py +124 -14
  80. package/package.json +11 -8
  81. package/scripts/build_vsix.mjs +72 -0
  82. package/scripts/bump_version.py +99 -0
  83. package/scripts/generate_diagrams.py +0 -1
  84. package/scripts/lint_v3.mjs +82 -18
  85. package/scripts/validate_release_artifacts.py +0 -1
  86. package/scripts/wheel_smoke.py +142 -0
  87. package/server.py +11 -7
  88. package/setup_wizard.py +1142 -0
  89. package/static/account.html +2 -4
  90. package/static/admin.html +3 -5
  91. package/static/chat.html +3 -6
  92. package/static/graph.html +2 -4
  93. package/static/sw.js +81 -52
  94. package/static/v3/asset-manifest.json +20 -19
  95. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  96. package/static/v3/css/lattice.base.css +1 -1
  97. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  98. package/static/v3/css/lattice.components.css +1 -1
  99. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  100. package/static/v3/css/lattice.shell.css +1 -1
  101. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  102. package/static/v3/css/lattice.tokens.css +3 -0
  103. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  104. package/static/v3/css/lattice.views.css +2 -2
  105. package/static/v3/index.html +3 -4
  106. package/static/v3/js/{app.d086489d.js → app.356e6452.js} +1 -1
  107. package/static/v3/js/core/{api.12b568ad.js → api.7a308b89.js} +39 -1
  108. package/static/v3/js/core/api.js +38 -0
  109. package/static/v3/js/core/{routes.d214b399.js → routes.7222343d.js} +22 -22
  110. package/static/v3/js/core/routes.js +22 -22
  111. package/static/v3/js/core/{shell.d05266f5.js → shell.a1657f20.js} +4 -4
  112. package/static/v3/js/core/shell.js +1 -1
  113. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  114. package/static/v3/js/core/store.js +1 -1
  115. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  116. package/static/v3/js/views/graph-canvas.js +509 -0
  117. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  118. package/static/v3/js/views/hybrid-search.js +1 -2
  119. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +509 -0
  120. package/static/v3/js/views/knowledge-graph.js +326 -54
  121. package/static/vendor/chart.umd.min.js +20 -0
  122. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  123. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  124. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  125. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  126. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  127. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  128. package/static/vendor/fonts/inter.css +44 -0
  129. package/static/vendor/icons/tabler-icons.min.css +4 -0
  130. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  131. package/static/vendor/marked.min.js +69 -0
  132. package/static/workspace.html +2 -2
  133. package/telegram_bot.py +1 -2
  134. package/tools/commands.py +4 -2
  135. package/tools/computer.py +1 -1
  136. package/tools/documents.py +1 -3
  137. package/tools/filesystem.py +0 -4
  138. package/tools/knowledge.py +1 -3
  139. package/tools/network.py +1 -3
  140. package/codex_telegram_bot.py +0 -195
  141. package/docs/assets/v3.4.0/agent-run.png +0 -0
  142. package/docs/assets/v3.4.0/agents.png +0 -0
  143. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  144. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  145. package/docs/assets/v3.4.0/chat.png +0 -0
  146. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  147. package/docs/assets/v3.4.0/files.png +0 -0
  148. package/docs/assets/v3.4.0/home.png +0 -0
  149. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  150. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  151. package/docs/assets/v3.4.0/local-agent.png +0 -0
  152. package/docs/assets/v3.4.0/memory.png +0 -0
  153. package/docs/assets/v3.4.0/settings.png +0 -0
  154. package/docs/assets/v3.4.0/vision-input.png +0 -0
  155. package/docs/assets/v3.4.0/workflows.png +0 -0
  156. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  157. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  158. package/docs/assets/v3.4.1/local-agent.png +0 -0
  159. package/docs/images/admin-dashboard.png +0 -0
  160. package/docs/images/architecture.png +0 -0
  161. package/docs/images/enterprise.png +0 -0
  162. package/docs/images/graph.png +0 -0
  163. package/docs/images/hero.gif +0 -0
  164. package/docs/images/knowledge-graph.png +0 -0
  165. package/docs/images/lattice-ai-demo.gif +0 -0
  166. package/docs/images/lattice-ai-hero.png +0 -0
  167. package/docs/images/logo.svg +0 -33
  168. package/docs/images/mobile-responsive.png +0 -0
  169. package/docs/images/model-recommendation.png +0 -0
  170. package/docs/images/onboarding.png +0 -0
  171. package/docs/images/organization.png +0 -0
  172. package/docs/images/pipeline.png +0 -0
  173. package/docs/images/screenshot-admin.png +0 -0
  174. package/docs/images/screenshot-chat.png +0 -0
  175. package/docs/images/screenshot-graph.png +0 -0
  176. package/docs/images/skills.png +0 -0
  177. package/docs/images/workspace-dark.png +0 -0
  178. package/docs/images/workspace-light.png +0 -0
  179. package/docs/images/workspace.png +0 -0
  180. package/requirements.txt +0 -16
  181. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
@@ -16,7 +16,7 @@ import logging
16
16
  import re
17
17
  import threading
18
18
  import time
19
- from dataclasses import dataclass, field, asdict
19
+ from dataclasses import dataclass, asdict
20
20
  from typing import Any, Dict, List, Optional, Tuple
21
21
 
22
22
  logger = logging.getLogger(__name__)
@@ -19,7 +19,6 @@
19
19
  from __future__ import annotations
20
20
 
21
21
  import logging
22
- import re
23
22
  from dataclasses import dataclass, field, asdict
24
23
  from enum import Enum
25
24
  from typing import Any, Dict, List, Optional
@@ -14,7 +14,7 @@ from datetime import datetime
14
14
  from typing import Any, Callable, Dict, List, Optional
15
15
 
16
16
 
17
- MULTI_AGENT_VERSION = "3.5.0"
17
+ MULTI_AGENT_VERSION = "4.0.0"
18
18
 
19
19
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
20
20
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -279,10 +279,13 @@ class AgentRunResult:
279
279
  retry_history: List[Dict[str, Any]] = field(default_factory=list)
280
280
  plan_review: Dict[str, Any] = field(default_factory=dict)
281
281
  memory_snapshots: List[Dict[str, Any]] = field(default_factory=list)
282
+ # "simulation" = deterministic LLM-free runner; "llm" = model-driven (v4 runtime).
283
+ mode: str = "simulation"
282
284
 
283
285
  def as_dict(self) -> Dict[str, Any]:
284
286
  return {
285
287
  "agent_id": self.agent_id,
288
+ "mode": self.mode,
286
289
  "status": self.status,
287
290
  "output": self.output,
288
291
  "timeline": self.timeline,
@@ -403,7 +406,221 @@ def default_role_runner(
403
406
  ctx.output = ctx.output or f"Released outcome for: {ctx.goal}"
404
407
  return {"role": role, "released": True, "summary": ctx.output}
405
408
 
406
- return {"role": role, "status": "noop"}
409
+ return {
410
+ "role": role,
411
+ "status": "skipped",
412
+ "reason": "this role has no deterministic behaviour (custom agents require a loaded model)",
413
+ }
414
+
415
+ return runner
416
+
417
+
418
+ def _extract_json_object(raw: str) -> Dict[str, Any]:
419
+ """Parse one JSON object out of an LLM response (fences/prose tolerated)."""
420
+ import json as _json
421
+ import re as _re
422
+
423
+ text = str(raw or "").strip()
424
+ fenced = _re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=_re.DOTALL)
425
+ if fenced:
426
+ text = fenced.group(1)
427
+ elif not text.startswith("{"):
428
+ start, end = text.find("{"), text.rfind("}")
429
+ if start >= 0 and end > start:
430
+ text = text[start : end + 1]
431
+ parsed = _json.loads(text)
432
+ if not isinstance(parsed, dict):
433
+ raise ValueError("model returned JSON that is not an object")
434
+ return parsed
435
+
436
+
437
+ def llm_role_runner(
438
+ *,
439
+ generate: Callable[..., str],
440
+ planner_prompt: str,
441
+ critic_prompt: str,
442
+ context_provider: Optional[Callable[[str], List[str]]] = None,
443
+ workflow_runner: Optional[Callable[..., Any]] = None,
444
+ plugin_runner: Optional[Callable[..., Any]] = None,
445
+ custom_agents: Optional[Dict[str, Dict[str, Any]]] = None,
446
+ ) -> Callable[[str, OrchestrationContext], Dict[str, Any]]:
447
+ """Model-driven role runner — the real Multi-Agent Runtime (T7b).
448
+
449
+ ``generate(message, context, max_tokens, temperature) -> str`` is a
450
+ synchronous bridge to the loaded model. Honesty contract (design-review
451
+ amendment): when the model responds but its plan/critique cannot be
452
+ parsed, the RUN FAILS with the raw output preserved in the records —
453
+ it never silently falls back to fabricated deterministic artifacts.
454
+ """
455
+ base = default_role_runner(
456
+ workflow_runner=workflow_runner,
457
+ plugin_runner=plugin_runner,
458
+ context_provider=context_provider,
459
+ )
460
+
461
+ def _fail(ctx: OrchestrationContext, role: str, reason: str, raw: str) -> Dict[str, Any]:
462
+ ctx.inputs["__llm_failure__"] = {"role": role, "reason": reason, "raw": raw[:2000]}
463
+ ctx.review = {
464
+ "outcome": "reject",
465
+ "verdict": "fail",
466
+ "reason": f"{role}: {reason}",
467
+ "raw_output": raw[:2000],
468
+ "reviewed_at": _now(),
469
+ }
470
+ return {"role": role, "status": "error", "reason": reason, "raw": raw[:2000]}
471
+
472
+ def runner(role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
473
+ failure = ctx.inputs.get("__llm_failure__")
474
+
475
+ custom = (custom_agents or {}).get(role)
476
+ if custom is not None:
477
+ # Executable registry entry (T7e): the agent's persisted config
478
+ # (system_prompt, max_tokens, temperature) is actually loaded.
479
+ cfg = custom.get("config") or {}
480
+ system = str(
481
+ cfg.get("system_prompt")
482
+ or custom.get("description")
483
+ or f"You are {custom.get('name') or role}."
484
+ )
485
+ try:
486
+ out = str(generate(
487
+ ctx.output or ctx.goal,
488
+ context=system,
489
+ max_tokens=int(cfg.get("max_tokens") or 1024),
490
+ temperature=float(cfg.get("temperature") or 0.2),
491
+ ))
492
+ except Exception as exc:
493
+ return _fail(ctx, role, f"custom agent generation failed ({exc})", "")
494
+ ctx.output = out
495
+ return {"role": role, "agent": custom.get("name"), "status": "ok",
496
+ "output": out[:2000]}
497
+
498
+ if role == "planner":
499
+ research = "\n".join(f"- {item}" for item in (ctx.research or [])[:8])
500
+ raw = generate(
501
+ "Produce a JSON execution plan for this goal. Respond with one JSON "
502
+ 'object: {"goal": str, "steps": [{"description": str}, ...]} and nothing else.',
503
+ context=f"{planner_prompt}\n\nGoal: {ctx.goal}\n\nKnown context:\n{research}",
504
+ max_tokens=1024,
505
+ temperature=0.1,
506
+ )
507
+ try:
508
+ parsed = _extract_json_object(str(raw))
509
+ except Exception as exc:
510
+ return _fail(ctx, role, f"plan output unparseable ({exc})", str(raw))
511
+ steps = []
512
+ for i, step in enumerate(parsed.get("steps") or []):
513
+ description = step.get("description") if isinstance(step, dict) else str(step)
514
+ steps.append({"index": i, "description": str(description or f"Step {i + 1}"), "status": "planned"})
515
+ if not steps:
516
+ return _fail(ctx, role, "model returned a plan with no steps", str(raw))
517
+ if ctx.inputs.get("workflow"):
518
+ steps[0]["workflow"] = ctx.inputs.get("workflow")
519
+ if ctx.inputs.get("plugin"):
520
+ steps[0]["plugin"] = ctx.inputs.get("plugin")
521
+ ctx.plan = steps
522
+ ctx.plan_id = f"plan-{abs(hash((ctx.goal, len(steps)))) % 10_000_000}"
523
+ ctx.plan_review = {
524
+ "plan_id": ctx.plan_id,
525
+ "outcome": "approve",
526
+ "reason": "model-generated plan parsed and bounded",
527
+ "reviewed_at": _now(),
528
+ }
529
+ return {"role": role, "plan_id": ctx.plan_id, "steps": len(steps), "plan": steps, "plan_review": ctx.plan_review}
530
+
531
+ if role == "executor":
532
+ if failure:
533
+ return {"role": role, "status": "error", "reason": f"skipped — {failure['role']} failed"}
534
+ results = []
535
+ for step in ctx.plan:
536
+ outcome: Dict[str, Any] = {"index": step["index"], "description": step["description"]}
537
+ wf = step.get("workflow")
538
+ pl = step.get("plugin")
539
+ if wf and workflow_runner is not None:
540
+ try:
541
+ outcome["workflow_result"] = workflow_runner(wf, ctx)
542
+ ctx.workflow_outputs.append(outcome["workflow_result"])
543
+ except Exception as exc:
544
+ outcome["workflow_error"] = str(exc)
545
+ if pl and plugin_runner is not None:
546
+ try:
547
+ outcome["plugin_result"] = plugin_runner(pl, ctx)
548
+ ctx.plugin_outputs.append(outcome["plugin_result"])
549
+ except Exception as exc:
550
+ outcome["plugin_error"] = str(exc)
551
+ try:
552
+ outcome["result"] = str(generate(
553
+ f"Execute this step and return the concrete result only.\n"
554
+ f"Goal: {ctx.goal}\nStep: {step['description']}",
555
+ context="",
556
+ max_tokens=1024,
557
+ temperature=0.2,
558
+ ))[:4000]
559
+ except Exception as exc:
560
+ outcome["error"] = str(exc)
561
+ if outcome.get("workflow_error") or outcome.get("plugin_error") or outcome.get("error"):
562
+ step["status"] = "failed"
563
+ outcome["status"] = "error"
564
+ else:
565
+ step["status"] = "done"
566
+ outcome["status"] = "done"
567
+ results.append(outcome)
568
+ ctx.executed = results
569
+ done = [r for r in results if r.get("status") == "done"]
570
+ ctx.output = "\n\n".join(str(r.get("result") or "") for r in done).strip() or (
571
+ f"Completed {len(done)}/{len(results)} step(s) for: {ctx.goal}"
572
+ )
573
+ return {"role": role, "executed": len(results), "results": results}
574
+
575
+ if role == "reviewer":
576
+ if failure:
577
+ # Fail-closed: an upstream unparseable model output means this
578
+ # run is failed, with the raw output preserved — never rescued
579
+ # by a rubber-stamp review.
580
+ ctx.review = {
581
+ "outcome": "reject",
582
+ "verdict": "fail",
583
+ "reason": f"{failure['role']} output unparseable",
584
+ "raw_output": failure.get("raw"),
585
+ "reviewed_at": _now(),
586
+ }
587
+ return {"role": role, **ctx.review}
588
+ raw = generate(
589
+ "Review this execution. Respond with one JSON object: "
590
+ '{"approve": bool, "reason": str} and nothing else.',
591
+ context=(
592
+ f"{critic_prompt}\n\nGoal: {ctx.goal}\n\n"
593
+ f"Steps: {[s.get('status') for s in ctx.plan]}\n\nOutput:\n{(ctx.output or '')[:3000]}"
594
+ ),
595
+ max_tokens=512,
596
+ temperature=0.1,
597
+ )
598
+ try:
599
+ parsed = _extract_json_object(str(raw))
600
+ approve = bool(parsed.get("approve"))
601
+ reason = str(parsed.get("reason") or "")
602
+ except Exception as exc:
603
+ ctx.review = {
604
+ "outcome": "reject",
605
+ "verdict": "fail",
606
+ "reason": f"critic output unparseable ({exc})",
607
+ "raw_output": str(raw)[:2000],
608
+ "reviewed_at": _now(),
609
+ }
610
+ return {"role": role, **ctx.review}
611
+ ctx.review = {
612
+ "outcome": "approve" if approve else "retry",
613
+ "verdict": "pass" if approve else "retry",
614
+ "reason": reason or ("model approved the result" if approve else "model requested a retry"),
615
+ "confidence": 0.9 if approve else 0.4,
616
+ "notes": [],
617
+ "reviewed_at": _now(),
618
+ }
619
+ return {"role": role, **ctx.review}
620
+
621
+ # researcher / release / anything else: the deterministic behaviour is
622
+ # real work (memory recall, bookkeeping) — reuse it.
623
+ return base(role, ctx)
407
624
 
408
625
  return runner
409
626
 
@@ -411,8 +628,21 @@ def default_role_runner(
411
628
  class MultiAgentOrchestrator:
412
629
  """Drives a role pipeline with handoff, planning, review, and retry."""
413
630
 
414
- def __init__(self, role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None):
631
+ def __init__(
632
+ self,
633
+ role_runner: Optional[Callable[[str, OrchestrationContext], Dict[str, Any]]] = None,
634
+ mode: str = "simulation",
635
+ custom_agents: Optional[Dict[str, Dict[str, Any]]] = None,
636
+ ):
415
637
  self.role_runner = role_runner or default_role_runner()
638
+ # Executable registry entries (T7e): a requested role may be a
639
+ # registered custom agent id; its config (system_prompt, …) is
640
+ # actually loaded at run time — registration is no longer a UI illusion.
641
+ self.custom_agents = dict(custom_agents or {})
642
+ # Honest execution-mode label persisted on every run record. The
643
+ # built-in runner never calls a model, so the default is "simulation";
644
+ # an LLM-backed runner must declare mode="llm" explicitly.
645
+ self.mode = mode
416
646
 
417
647
  def _run_role(self, role: str, ctx: OrchestrationContext) -> Dict[str, Any]:
418
648
  started = _now()
@@ -473,7 +703,10 @@ class MultiAgentOrchestrator:
473
703
  workspace_id=workspace_id,
474
704
  inputs=inputs or {},
475
705
  )
476
- pipeline = [r for r in (roles or list(CORE_PIPELINE)) if r in AGENT_ROLES]
706
+ pipeline = [
707
+ r for r in (roles or list(CORE_PIPELINE))
708
+ if r in AGENT_ROLES or r in self.custom_agents
709
+ ]
477
710
  if not pipeline:
478
711
  pipeline = list(CORE_PIPELINE)
479
712
  max_retries = max(0, int(max_retries or 0))
@@ -558,4 +791,5 @@ class MultiAgentOrchestrator:
558
791
  retry_history=ctx.retry_history,
559
792
  plan_review=ctx.plan_review,
560
793
  memory_snapshots=ctx.memory_snapshots,
794
+ mode=self.mode,
561
795
  )
@@ -6,7 +6,7 @@ import re
6
6
  import secrets
7
7
  import threading
8
8
  import time
9
- from typing import Dict, List, Optional
9
+ from typing import Dict, List
10
10
 
11
11
  from fastapi import HTTPException
12
12
 
@@ -1,5 +1,12 @@
1
- """File-backed session store with sliding-window TTL."""
1
+ """File-backed session store with sliding-window TTL.
2
2
 
3
+ v4: bearer tokens are stored **hashed** (sha256) at rest — a process that can
4
+ read ``sessions.json`` must not be able to hijack every session. Pre-v4 files
5
+ holding raw tokens are migrated transparently on load (sessions survive the
6
+ upgrade; the raw token never touches disk again).
7
+ """
8
+
9
+ import hashlib
3
10
  import json
4
11
  import logging
5
12
  import os
@@ -13,6 +20,16 @@ SESSION_TTL = 60 * 60 * 24 # 24 hours
13
20
  SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
14
21
  _lock = threading.Lock()
15
22
 
23
+ _HEX64 = frozenset("0123456789abcdef")
24
+
25
+
26
+ def _hash_token(token: str) -> str:
27
+ return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
28
+
29
+
30
+ def _looks_hashed(key: str) -> bool:
31
+ return len(key) == 64 and set(key) <= _HEX64
32
+
16
33
 
17
34
  def _sessions_file(data_dir: Optional[Path] = None) -> Path:
18
35
  d = data_dir or Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
@@ -25,7 +42,19 @@ def load_sessions(data_dir: Optional[Path] = None) -> Dict[str, tuple]:
25
42
  f = _sessions_file(data_dir)
26
43
  if f.exists():
27
44
  raw = json.loads(f.read_text())
28
- return {k: tuple(v) for k, v in raw.items()}
45
+ sessions: Dict[str, tuple] = {}
46
+ migrated = False
47
+ for key, value in raw.items():
48
+ if _looks_hashed(key):
49
+ sessions[key] = tuple(value)
50
+ else:
51
+ # Pre-v4 entry: the key IS the raw bearer token. Re-key it
52
+ # under its hash so the plaintext stops living on disk.
53
+ sessions[_hash_token(key)] = tuple(value)
54
+ migrated = True
55
+ if migrated:
56
+ persist_sessions(sessions, data_dir)
57
+ return sessions
29
58
  except Exception as e:
30
59
  logging.warning("load_sessions failed (starting empty): %s", e)
31
60
  return {}
@@ -46,27 +75,28 @@ class SessionStore:
46
75
  def create(self, email: str) -> str:
47
76
  token = secrets.token_urlsafe(32)
48
77
  with _lock:
49
- self._sessions[token] = (email, time.time())
78
+ self._sessions[_hash_token(token)] = (email, time.time())
50
79
  persist_sessions(self._sessions, self._data_dir)
51
80
  return token
52
81
 
53
82
  def get_email(self, token: str) -> Optional[str]:
54
83
  now = time.time()
84
+ key = _hash_token(token)
55
85
  with _lock:
56
- entry = self._sessions.get(token)
86
+ entry = self._sessions.get(key)
57
87
  if entry is None:
58
88
  return None
59
89
  email, created_at = entry
60
90
  if now - created_at > SESSION_TTL:
61
- self._sessions.pop(token, None)
91
+ self._sessions.pop(key, None)
62
92
  persist_sessions(self._sessions, self._data_dir)
63
93
  return None
64
94
  if now - created_at > SESSION_REFRESH_THRESHOLD:
65
- self._sessions[token] = (email, now)
95
+ self._sessions[key] = (email, now)
66
96
  persist_sessions(self._sessions, self._data_dir)
67
97
  return email
68
98
 
69
99
  def invalidate(self, token: str) -> None:
70
100
  with _lock:
71
- self._sessions.pop(token, None)
101
+ self._sessions.pop(_hash_token(token), None)
72
102
  persist_sessions(self._sessions, self._data_dir)
@@ -185,11 +185,17 @@ def _evaluate_condition(config: Dict[str, Any], context: Dict[str, Any]) -> bool
185
185
  class WorkflowRun:
186
186
  workflow_id: Optional[str]
187
187
  name: str
188
- status: str = "ok" # ok | failed | partial
188
+ status: str = "ok" # ok | failed | partial | awaiting_approval
189
189
  timeline: List[Dict[str, Any]] = field(default_factory=list)
190
190
  outputs: Dict[str, Any] = field(default_factory=dict)
191
191
  started_at: str = field(default_factory=_now)
192
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
193
199
 
194
200
  def as_dict(self) -> Dict[str, Any]:
195
201
  return {
@@ -201,9 +207,36 @@ class WorkflowRun:
201
207
  "started_at": self.started_at,
202
208
  "finished_at": self.finished_at,
203
209
  "step_count": len(self.timeline),
210
+ "paused_node": self.paused_node,
211
+ "pending_approval": self.pending_approval,
212
+ "paused_context": self.paused_context,
204
213
  }
205
214
 
206
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
+
207
240
  class WorkflowEngine:
208
241
  """Interprets a validated workflow definition over injected runners.
209
242
 
@@ -212,6 +245,11 @@ class WorkflowEngine:
212
245
  node as ``skipped`` with a reason rather than failing the whole run, so a
213
246
  workflow that references a capability the host has not wired degrades
214
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.
215
253
  """
216
254
 
217
255
  def __init__(self, runners: Optional[Dict[str, Callable[..., Any]]] = None, *, hooks: Any = None):
@@ -243,8 +281,60 @@ class WorkflowEngine:
243
281
 
244
282
  nodes = {node["id"]: node for node in definition["nodes"]}
245
283
  context: Dict[str, Any] = {"inputs": inputs or {}, **(inputs or {})}
246
-
247
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:
248
338
  steps = 0
249
339
  had_error = False
250
340
  had_skip = False
@@ -301,6 +391,28 @@ class WorkflowEngine:
301
391
  entry["result"] = result
302
392
  context["last_output"] = result
303
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
304
416
  except Exception as exc:
305
417
  entry["status"] = "error"
306
418
  entry["reason"] = str(exc)