ltcai 3.6.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.
- package/README.md +11 -7
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +47 -53
- package/kg_schema.py +93 -10
- package/knowledge_graph.py +362 -33
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +7 -1
- package/latticeai/api/auth.py +27 -4
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/knowledge_graph.py +146 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +2 -4
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/workflow_designer.py +46 -0
- package/latticeai/api/workspace.py +71 -49
- package/latticeai/app_factory.py +1710 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +37 -7
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +58 -10
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +1 -0
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +31 -0
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +9 -7
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +82 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/account.html +2 -4
- package/static/admin.html +3 -5
- package/static/chat.html +3 -6
- package/static/graph.html +2 -4
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +20 -19
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
- package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
- package/static/v3/js/core/routes.js +22 -22
- package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
- package/static/v3/js/core/shell.js +1 -1
- package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
- package/static/v3/js/core/store.js +1 -1
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
- package/static/v3/js/views/knowledge-graph.js +33 -37
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/static/workspace.html +2 -2
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
|
@@ -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 = "
|
|
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 {
|
|
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__(
|
|
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 = [
|
|
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
|
)
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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[
|
|
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)
|