ltcai 11.5.0 → 11.5.2
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 +86 -47
- package/docs/CHANGELOG.md +76 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
- package/docs/MULTI_AGENT_RUNTIME.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/WORKFLOW_DESIGNER.md +1 -1
- package/docs/architecture.md +4 -4
- package/docs/kg-schema.md +1 -1
- package/docs/v11.5.1_RUST_FULL_LOOP_PLAN.md +85 -0
- package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +1 -1
- package/lattice_brain/graph/projection/v2_schema.py +0 -28
- package/lattice_brain/graph/vector_index/base.py +0 -3
- package/lattice_brain/graph/vector_index/brute_force.py +0 -4
- package/lattice_brain/graph/vector_index/hnsw.py +0 -3
- package/lattice_brain/graph/vector_index/quantized.py +0 -3
- package/lattice_brain/ingestion_jobs.py +0 -3
- package/lattice_brain/memory.py +0 -27
- package/lattice_brain/portability/sharing.py +0 -4
- package/lattice_brain/quality.py +0 -49
- package/lattice_brain/runtime/agent_runtime.py +1 -1
- package/lattice_brain/runtime/hooks.py +0 -13
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +0 -4
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +12 -12
- package/latticeai/api/agent_worker_seam.py +389 -0
- package/latticeai/api/auth.py +25 -2
- package/latticeai/api/chat.py +3 -3
- package/latticeai/api/chat_agent_http.py +3 -12
- package/latticeai/api/chat_helpers.py +10 -13
- package/latticeai/api/computer_use.py +31 -42
- package/latticeai/api/health.py +21 -1
- package/latticeai/api/local_files.py +14 -0
- package/latticeai/api/permissions.py +38 -12
- package/latticeai/api/search.py +24 -0
- package/latticeai/api/static_routes.py +4 -1
- package/latticeai/cli/runtime.py +7 -3
- package/latticeai/core/config.py +47 -3
- package/latticeai/core/csrf.py +28 -2
- package/latticeai/core/embedding_providers/__init__.py +3 -5
- package/latticeai/core/embedding_providers/base.py +1 -1
- package/latticeai/core/embedding_providers/text.py +1 -1
- package/latticeai/core/enterprise.py +1 -4
- package/latticeai/core/http_origin.py +146 -0
- package/latticeai/core/invitations.py +3 -2
- package/latticeai/core/io_utils.py +2 -11
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +40 -0
- package/latticeai/core/model_compat.py +2 -8
- package/latticeai/core/module_probe.py +37 -0
- package/latticeai/core/run_explain.py +5 -2
- package/latticeai/core/run_store.py +2 -2
- package/latticeai/core/security.py +13 -0
- package/latticeai/core/sessions.py +3 -2
- package/latticeai/core/sse.py +25 -0
- package/latticeai/core/users.py +0 -9
- package/latticeai/core/workspace_graph_trace.py +2 -8
- package/latticeai/core/workspace_os.py +0 -10
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_os_utils.py +34 -0
- package/latticeai/core/workspace_review_items.py +2 -8
- package/latticeai/core/workspace_runs.py +2 -8
- package/latticeai/core/workspace_skills.py +2 -7
- package/latticeai/models/router/documents.py +0 -35
- package/latticeai/models/router/registry.py +0 -4
- package/latticeai/runtime/access_runtime.py +16 -4
- package/latticeai/runtime/build_phases/features.py +25 -2
- package/latticeai/runtime/build_phases/web.py +2 -0
- package/latticeai/runtime/feature_toggle_wiring.py +12 -18
- package/latticeai/runtime/network_boundary_wiring.py +8 -19
- package/latticeai/runtime/permission_mode_wiring.py +6 -13
- package/latticeai/runtime/router_registration.py +4 -0
- package/latticeai/runtime/runtime_context.py +1 -13
- package/latticeai/runtime/service_singletons.py +55 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence/proposals.py +0 -5
- package/latticeai/services/change_proposals.py +2 -36
- package/latticeai/services/chronicle.py +4 -6
- package/latticeai/services/command_center.py +4 -4
- package/latticeai/services/evidence_actions.py +5 -2
- package/latticeai/services/hybrid_chat.py +2 -2
- package/latticeai/services/hybrid_policy.py +8 -57
- package/latticeai/services/mode_store.py +132 -0
- package/latticeai/services/model_capability_registry.py +0 -15
- package/latticeai/services/model_catalog.py +0 -7
- package/latticeai/services/model_loading.py +3 -2
- package/latticeai/services/model_runtime/__init__.py +0 -57
- package/latticeai/services/model_runtime/engines.py +27 -128
- package/latticeai/services/model_runtime/loading.py +2 -2
- package/latticeai/services/network_boundary_service.py +7 -44
- package/latticeai/services/permission_mode_service.py +8 -56
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/setup_detection.py +67 -1
- package/latticeai/services/tool_dispatch.py +0 -4
- package/latticeai/services/upload_service.py +7 -20
- package/latticeai/services/workspace_service.py +0 -6
- package/latticeai/setup/auto_setup.py +15 -26
- package/latticeai/setup/wizard/catalog.py +5 -2
- package/latticeai/setup/wizard/detect.py +6 -27
- package/latticeai/setup/wizard/paths.py +2 -5
- package/latticeai/setup/wizard/plans.py +2 -2
- package/package.json +2 -4
- package/scripts/bump_version.py +14 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_legacy_debt.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_agent_loop_fixtures.py +994 -0
- package/scripts/generate_rust_parity_fixtures.py +72 -161
- package/scripts/parity_fixture_corpus_context.py +162 -0
- package/scripts/parity_fixture_corpus_docgen.py +341 -0
- package/scripts/release_screen_claims.json +20 -0
- package/src-tauri/Cargo.lock +13 -7
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/backend.rs +76 -2
- package/src-tauri/src/main.rs +13 -3
- package/src-tauri/tauri.conf.json +2 -2
- package/static/app/asset-manifest.json +41 -41
- package/static/app/assets/{Act-CWnxSCgN.js → Act-DcQizkl1.js} +1 -1
- package/static/app/assets/{AdminConsole-BEQYU6kF.js → AdminConsole-cf4npybT.js} +1 -1
- package/static/app/assets/{Brain-DWu1BhFg.js → Brain-3VCSHFcn.js} +1 -1
- package/static/app/assets/{BrainHome-95Hilr9R.js → BrainHome-Qm8eaztx.js} +1 -1
- package/static/app/assets/{BrainSignals-QdeqCpAF.js → BrainSignals-DS9BtKOW.js} +1 -1
- package/static/app/assets/{Capture-BHpCxnzb.js → Capture-DiQ219jW.js} +1 -1
- package/static/app/assets/{Chronicle-B4xYKoed.js → Chronicle-BGvuAchH.js} +1 -1
- package/static/app/assets/{CommandPalette-BVXnttSz.js → CommandPalette-Bqhm0Urn.js} +1 -1
- package/static/app/assets/{Library-DgYcHome.js → Library-BV6NnF0a.js} +1 -1
- package/static/app/assets/{LivingBrain-CrJLDbf7.js → LivingBrain-GzenJchP.js} +1 -1
- package/static/app/assets/{ProductFlow-DFlScKoJ.js → ProductFlow-DEP6-vML.js} +1 -1
- package/static/app/assets/{ReviewCard-Cy5f48Pj.js → ReviewCard-CNZ7XjWG.js} +1 -1
- package/static/app/assets/{System-NF8IfhTa.js → System-CieofHQa.js} +1 -1
- package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
- package/static/app/assets/{bot-CucuhLhm.js → bot-B_K1Tdmw.js} +1 -1
- package/static/app/assets/{brain-BBnSryW_.js → brain-DWyaV1L1.js} +1 -1
- package/static/app/assets/{button-C2GUj2Ai.js → button-aTn4s84A.js} +1 -1
- package/static/app/assets/circle-check-qqLug9nU.js +1 -0
- package/static/app/assets/{circle-pause-CbkWzBmG.js → circle-pause-xKgeGXkT.js} +1 -1
- package/static/app/assets/{circle-play-7lEaqHdJ.js → circle-play-DkT6tYPX.js} +1 -1
- package/static/app/assets/{cpu-DAlCXlIy.js → cpu-85xYObUC.js} +1 -1
- package/static/app/assets/{download-RNhuuJwh.js → download-B5Fm7YXo.js} +1 -1
- package/static/app/assets/{folder-open-CLW4odzM.js → folder-open-kk2Xa52u.js} +1 -1
- package/static/app/assets/{hard-drive-NKEiDIAJ.js → hard-drive-DkA3zBW_.js} +1 -1
- package/static/app/assets/{index-DMurvUuR.js → index-BMPdTmlY.js} +3 -3
- package/static/app/assets/index-DxmOfNRi.css +2 -0
- package/static/app/assets/{input-D2UhPC1X.js → input-B0nRf2jO.js} +1 -1
- package/static/app/assets/{link-2-6amKbP_P.js → link-2-Dwb4gnTc.js} +1 -1
- package/static/app/assets/{permissionCopy-Cu9TZtdR.js → permissionCopy-CQDUBrOZ.js} +1 -1
- package/static/app/assets/{primitives-gPsccucr.js → primitives-SNp0LRJz.js} +1 -1
- package/static/app/assets/search-BcHqkjoy.js +1 -0
- package/static/app/assets/{share-2-Bau7KkPq.js → share-2-BsrxFglO.js} +1 -1
- package/static/app/assets/{shield-alert-BufNYypi.js → shield-alert-5BStfp2_.js} +1 -1
- package/static/app/assets/{textarea-BQnVWhYs.js → textarea-Cg8IUA-k.js} +1 -1
- package/static/app/assets/{useFocusTrap-B3_w60si.js → useFocusTrap-CYKvE46M.js} +1 -1
- package/static/app/assets/{useMutation-BHhCflT6.js → useMutation-CSn9t1op.js} +1 -1
- package/static/app/assets/{useQuery-rBWfI-5t.js → useQuery-CY2OI2uy.js} +1 -1
- package/static/app/assets/{utils-V_5-wxr5.js → utils-Ddol2RWD.js} +1 -1
- package/static/app/assets/{workspace-K1zjYUHj.js → workspace-BqDwOz_p.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/desktop/electron/README.md +0 -9
- package/desktop/electron/main.cjs +0 -58
- package/desktop/electron/preload.cjs +0 -5
- package/latticeai/core/graph_curator.py +0 -11
- package/latticeai/core/hooks.py +0 -11
- package/latticeai/core/local_embeddings.py +0 -104
- package/latticeai/core/multi_agent.py +0 -11
- package/latticeai/core/workflow_engine.py +0 -11
- package/latticeai/services/ingestion.py +0 -11
- package/latticeai/services/kg_portability.py +0 -11
- package/latticeai/services/multimodal_streaming.py +0 -129
- package/scripts/measure_brain_home_fill.mjs +0 -141
- package/static/app/assets/arrow-left-DwkSYrjR.js +0 -1
- package/static/app/assets/circle-check-CxOVPwYq.js +0 -1
- package/static/app/assets/index-BLPb5lmE.css +0 -2
- package/static/app/assets/search-Cj_TKk_2.js +0 -1
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""AI-Worker seam (v11.5.1, plan §Y1) — the three calls the Rust loop makes back.
|
|
2
|
+
|
|
3
|
+
Once the agent loop moves into ``lattice-agent`` (plan §Y2), Python stops being
|
|
4
|
+
the orchestrator and becomes exactly what the system diagram already draws: the
|
|
5
|
+
**AI Worker**. It infers with the loaded model, it runs tool handlers, and it
|
|
6
|
+
stages proposals. The Rust kernel decides *what* to do next; it has to call
|
|
7
|
+
Python to actually do it.
|
|
8
|
+
|
|
9
|
+
Reconnaissance found no surface it could call:
|
|
10
|
+
|
|
11
|
+
* there is no bare completion endpoint — every LLM route (``/chat``,
|
|
12
|
+
``/agent``) also writes history, assembles context, or drives the whole
|
|
13
|
+
Python loop, none of which a Rust orchestrator wants;
|
|
14
|
+
* HTTP cannot create a change proposal at all — ``ChangeProposalService.review``
|
|
15
|
+
is reachable only from the in-process agent runtime;
|
|
16
|
+
* ``/tools/*`` is the **direct** surface: it runs ``enforce_policy``, which
|
|
17
|
+
denies anything not auto-approved (403) and never stages a proposal. Correct
|
|
18
|
+
for a human clicking a button, useless for a governed loop.
|
|
19
|
+
|
|
20
|
+
So this module adds the three seams, and nothing else:
|
|
21
|
+
|
|
22
|
+
``POST /agent/llm``
|
|
23
|
+
One completion. No history, no context assembly, no persistence of any
|
|
24
|
+
kind — the whole body is one ``generate_as`` await.
|
|
25
|
+
|
|
26
|
+
``POST /agent/tool``
|
|
27
|
+
One governed tool call: the mode-invariant guards, the role check, then the
|
|
28
|
+
shared ``pre_tool`` → execute → ``post_tool`` lifecycle.
|
|
29
|
+
|
|
30
|
+
``POST /agent/change-proposal``
|
|
31
|
+
The governor's verdict, verbatim, so the Rust loop can take the
|
|
32
|
+
proposal-first path for edits to existing files.
|
|
33
|
+
|
|
34
|
+
Two boundaries stated here so the payloads are not read as more than they are:
|
|
35
|
+
|
|
36
|
+
* **The guards are re-run on the server, always.** The Rust kernel preflights
|
|
37
|
+
permission mode before it ever calls; this seam still re-derives the policy,
|
|
38
|
+
still asks :func:`~latticeai.core.permission_mode.is_circuit_breaker`, and
|
|
39
|
+
still asks :func:`~latticeai.core.tool_governor.classify_tool_call`. A
|
|
40
|
+
compromised or buggy kernel therefore cannot widen what Python will execute —
|
|
41
|
+
the mode-invariant denials are defence in depth, not a duplicated preflight.
|
|
42
|
+
What the seam deliberately does *not* own is mode gating itself (which of the
|
|
43
|
+
approval-requiring steps may run): that is the kernel's decision, made with
|
|
44
|
+
the run's approval state, which HTTP does not have.
|
|
45
|
+
|
|
46
|
+
* **``workspace_id`` is attribution, not authorization.** Tools resolve their
|
|
47
|
+
own paths under ``AGENT_ROOT`` (or the home sandbox, via their own guards);
|
|
48
|
+
they are not workspace-scoped resources the way ``/api/*`` reads are. The
|
|
49
|
+
field is forwarded to the hook lifecycle so an audit event lands in the right
|
|
50
|
+
workspace, and it is not used to widen or narrow what may run. A caller
|
|
51
|
+
cannot reach another workspace's data by naming it here, because no code path
|
|
52
|
+
downstream consults it for that.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
import asyncio
|
|
58
|
+
import os
|
|
59
|
+
from typing import Any, Callable, Dict, Optional
|
|
60
|
+
|
|
61
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
62
|
+
from pydantic import BaseModel, Field
|
|
63
|
+
|
|
64
|
+
from lattice_brain.runtime.hooks import dispatch_tool
|
|
65
|
+
from latticeai.core.messages import http_error, resolve_language
|
|
66
|
+
from latticeai.core.permission_mode import is_circuit_breaker
|
|
67
|
+
from latticeai.core.tool_governor import classify_tool_call
|
|
68
|
+
from latticeai.tools import ToolError
|
|
69
|
+
|
|
70
|
+
#: Host-injected switch. Off by default: the loop seam is for a worker the
|
|
71
|
+
#: ``lattice-host`` supervisor started for itself, never for a browser that
|
|
72
|
+
#: happens to hold a session cookie. Read per request, not at import, so the
|
|
73
|
+
#: answer follows the process environment as it actually is.
|
|
74
|
+
SEAM_ENV_VAR = "LATTICEAI_AGENT_TOOL_SEAM"
|
|
75
|
+
|
|
76
|
+
#: Bounds on one completion. The ceiling is twice ``generate_as``'s own default
|
|
77
|
+
#: — enough for a long verification pass, short of a request that would hold the
|
|
78
|
+
#: single MLX executor for minutes.
|
|
79
|
+
MIN_MAX_TOKENS = 1
|
|
80
|
+
MAX_MAX_TOKENS = 8192
|
|
81
|
+
MIN_TEMPERATURE = 0.0
|
|
82
|
+
MAX_TEMPERATURE = 2.0
|
|
83
|
+
|
|
84
|
+
#: Rate-limit bucket. Deliberately *not* the ``"agent"`` bucket ``/agent`` uses:
|
|
85
|
+
#: that one is sized per *run* (10 burst, one refill per 10s) because one HTTP
|
|
86
|
+
#: call there is a whole agent run. Here one call is a single loop step, and a
|
|
87
|
+
#: Rust run makes a dozen of them, so reusing ``"agent"`` would 429 the loop
|
|
88
|
+
#: mid-run. This key is absent from ``_RATE_LIMITS``, so it takes the module
|
|
89
|
+
#: default (60 burst, 1/s) — a real per-user ceiling at per-step granularity.
|
|
90
|
+
SEAM_RATE_BUCKET = "agent_seam"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AgentLLMRequest(BaseModel):
|
|
94
|
+
"""One completion, with the model chosen per call.
|
|
95
|
+
|
|
96
|
+
``model_id`` omitted means the router's current default; a model that is
|
|
97
|
+
not cached makes ``generate_as`` answer ``"No model."``, which is returned
|
|
98
|
+
verbatim rather than dressed up as an error — the loop records it as the
|
|
99
|
+
step's text and re-plans, exactly as the Python loop does.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
model_id: Optional[str] = None
|
|
103
|
+
message: str
|
|
104
|
+
context: Optional[str] = None
|
|
105
|
+
max_tokens: int = 4096
|
|
106
|
+
temperature: float = 0.2
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AgentToolRequest(BaseModel):
|
|
110
|
+
"""One governed tool call on behalf of the authenticated user."""
|
|
111
|
+
|
|
112
|
+
tool: str
|
|
113
|
+
args: Dict[str, Any] = Field(default_factory=dict)
|
|
114
|
+
workspace_id: Optional[str] = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AgentChangeProposalRequest(BaseModel):
|
|
118
|
+
"""A governor consultation for a write that may touch existing content.
|
|
119
|
+
|
|
120
|
+
``policy`` is optional: the Rust kernel already holds the policy it
|
|
121
|
+
preflighted with, and passing it back keeps the two sides deciding on the
|
|
122
|
+
same facts. Omitted, the registry's own policy for this call is used.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
tool: str
|
|
126
|
+
args: Dict[str, Any] = Field(default_factory=dict)
|
|
127
|
+
policy: Optional[Dict[str, Any]] = None
|
|
128
|
+
workspace_id: Optional[str] = None
|
|
129
|
+
conversation_id: Optional[str] = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _seam_open() -> bool:
|
|
133
|
+
"""Whether this process is a worker the host opened the seam for."""
|
|
134
|
+
return os.environ.get(SEAM_ENV_VAR) == "1"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _path_probe(
|
|
138
|
+
dispatch_service: Any, tool: str
|
|
139
|
+
) -> Optional[Callable[[str], bool]]:
|
|
140
|
+
"""The *same* existence probe the direct surface classifies with.
|
|
141
|
+
|
|
142
|
+
``classify_tool_call`` asks "does the target already exist?" to tell an
|
|
143
|
+
additive create from an overwrite. ``ToolDispatchService`` answers that
|
|
144
|
+
through ``_governed_path_exists``, which resolves document creators'
|
|
145
|
+
``filename`` through their real output directory first — checking the raw
|
|
146
|
+
argument inspects a path nothing ever writes. Reusing that method is the
|
|
147
|
+
point: a second, subtly different probe here would let this seam and
|
|
148
|
+
``/tools/*`` disagree about whether a file exists, and the disagreement
|
|
149
|
+
would show up as one surface staging a proposal while the other overwrites.
|
|
150
|
+
|
|
151
|
+
``classify_tool_call`` types ``path_exists`` as optional, so a dispatch
|
|
152
|
+
service without the method (an injected fake, a future slimmer port) yields
|
|
153
|
+
``None`` and every target-write call classifies as additive. That is the
|
|
154
|
+
weaker guard, and it is the honest one: claiming a file does not exist is
|
|
155
|
+
what "we could not look" means to this classifier.
|
|
156
|
+
"""
|
|
157
|
+
probe = getattr(dispatch_service, "_governed_path_exists", None)
|
|
158
|
+
if not callable(probe):
|
|
159
|
+
return None
|
|
160
|
+
return lambda candidate: bool(probe(tool, candidate))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def create_agent_worker_seam_router(
|
|
164
|
+
*,
|
|
165
|
+
model_router: Any,
|
|
166
|
+
dispatch_service: Any,
|
|
167
|
+
execute_tool: Callable[[str, Dict[str, Any]], Any],
|
|
168
|
+
hooks: Any,
|
|
169
|
+
change_proposals: Any,
|
|
170
|
+
require_user: Callable[[Request], Any],
|
|
171
|
+
enforce_rate_limit: Callable[[str, str], None],
|
|
172
|
+
) -> APIRouter:
|
|
173
|
+
router = APIRouter()
|
|
174
|
+
|
|
175
|
+
def _require_seam(request: Request) -> None:
|
|
176
|
+
"""404 unless the host opened the seam for this worker.
|
|
177
|
+
|
|
178
|
+
The detail says *why* rather than imitating a missing route. Hiding it
|
|
179
|
+
would buy nothing: the paths are in the generated OpenAPI schema either
|
|
180
|
+
way, this is a local-first Brain rather than a multi-tenant service, and
|
|
181
|
+
an operator who forgot the environment variable otherwise gets a bare
|
|
182
|
+
404 with nothing to act on.
|
|
183
|
+
"""
|
|
184
|
+
if not _seam_open():
|
|
185
|
+
raise http_error(404, "agent_seam.disabled", resolve_language(request))
|
|
186
|
+
|
|
187
|
+
def _admit(request: Request) -> str:
|
|
188
|
+
"""Authenticate and charge this call against the per-step budget."""
|
|
189
|
+
current_user = require_user(request)
|
|
190
|
+
enforce_rate_limit(current_user, SEAM_RATE_BUCKET)
|
|
191
|
+
return str(current_user or "")
|
|
192
|
+
|
|
193
|
+
def _guard(tool: str, args: Dict[str, Any], language: str) -> Dict[str, Any]:
|
|
194
|
+
"""The mode-invariant denials, re-derived server-side.
|
|
195
|
+
|
|
196
|
+
One call to :func:`is_circuit_breaker` covers both denials the plan
|
|
197
|
+
names. The destructive-policy check is *inside* it — ``permission_mode``
|
|
198
|
+
answers ``"destructive action is always blocked"`` for
|
|
199
|
+
``policy["destructive"]`` or ``risk == "destructive"`` before it looks
|
|
200
|
+
at anything else. Writing a second, separate destructive check here (as
|
|
201
|
+
``enforce_policy`` does) would be code that can never run, and
|
|
202
|
+
unreachable code is not a guard.
|
|
203
|
+
"""
|
|
204
|
+
policy = dispatch_service.policy_for(tool, args)
|
|
205
|
+
breaker = is_circuit_breaker(tool, dict(policy), args)
|
|
206
|
+
if breaker:
|
|
207
|
+
raise http_error(
|
|
208
|
+
403,
|
|
209
|
+
"agent_seam.tool_blocked",
|
|
210
|
+
language,
|
|
211
|
+
tool=tool,
|
|
212
|
+
reason=breaker,
|
|
213
|
+
)
|
|
214
|
+
verdict = classify_tool_call(
|
|
215
|
+
tool,
|
|
216
|
+
args,
|
|
217
|
+
policy=dict(policy),
|
|
218
|
+
path_exists=_path_probe(dispatch_service, tool),
|
|
219
|
+
)
|
|
220
|
+
if verdict.get("fail_closed"):
|
|
221
|
+
raise http_error(
|
|
222
|
+
409,
|
|
223
|
+
"agent_seam.tool_fail_closed",
|
|
224
|
+
language,
|
|
225
|
+
tool=tool,
|
|
226
|
+
reason=str(verdict.get("reason") or ""),
|
|
227
|
+
)
|
|
228
|
+
return dict(policy)
|
|
229
|
+
|
|
230
|
+
@router.post("/agent/llm")
|
|
231
|
+
async def agent_llm(req: AgentLLMRequest, request: Request):
|
|
232
|
+
"""Generate once. Writes nothing, remembers nothing.
|
|
233
|
+
|
|
234
|
+
Not gated by ``LATTICEAI_AGENT_TOOL_SEAM``: a completion has no side
|
|
235
|
+
effect to gate, and the Rust loop is not the only caller that wants one
|
|
236
|
+
(``/rust/context/document`` composes a prompt the same way). Auth and
|
|
237
|
+
the per-step rate limit are the whole ceremony.
|
|
238
|
+
|
|
239
|
+
Structural problems in the body — a missing ``message``, a string where
|
|
240
|
+
a number belongs — answer with FastAPI's own 422, uniform with every
|
|
241
|
+
other router. Semantic ones answer in the caller's language.
|
|
242
|
+
"""
|
|
243
|
+
_admit(request)
|
|
244
|
+
language = resolve_language(request)
|
|
245
|
+
if not req.message.strip():
|
|
246
|
+
raise http_error(422, "agent_seam.message_required", language)
|
|
247
|
+
if req.max_tokens < MIN_MAX_TOKENS or req.max_tokens > MAX_MAX_TOKENS:
|
|
248
|
+
raise http_error(
|
|
249
|
+
422,
|
|
250
|
+
"agent_seam.max_tokens_out_of_range",
|
|
251
|
+
language,
|
|
252
|
+
min=MIN_MAX_TOKENS,
|
|
253
|
+
max=MAX_MAX_TOKENS,
|
|
254
|
+
)
|
|
255
|
+
if req.temperature < MIN_TEMPERATURE or req.temperature > MAX_TEMPERATURE:
|
|
256
|
+
raise http_error(
|
|
257
|
+
422,
|
|
258
|
+
"agent_seam.temperature_out_of_range",
|
|
259
|
+
language,
|
|
260
|
+
min=MIN_TEMPERATURE,
|
|
261
|
+
max=MAX_TEMPERATURE,
|
|
262
|
+
)
|
|
263
|
+
text = await model_router.generate_as(
|
|
264
|
+
req.model_id or None,
|
|
265
|
+
message=req.message,
|
|
266
|
+
context=req.context,
|
|
267
|
+
max_tokens=req.max_tokens,
|
|
268
|
+
temperature=req.temperature,
|
|
269
|
+
)
|
|
270
|
+
return {"text": str(text)}
|
|
271
|
+
|
|
272
|
+
@router.post("/agent/tool")
|
|
273
|
+
async def agent_tool(req: AgentToolRequest, request: Request):
|
|
274
|
+
"""Run one tool through the same lifecycle the Python loop uses.
|
|
275
|
+
|
|
276
|
+
The answer shape mirrors the loop's own catch (``execution.py``): a
|
|
277
|
+
``ToolError``/``KeyError``/``TypeError``/``PermissionError`` is the
|
|
278
|
+
*step's* outcome, not the request's, so it comes back 200 with
|
|
279
|
+
``{"error": ...}`` for the transcript. A denial — role, circuit
|
|
280
|
+
breaker, fail-closed governance — is the request's outcome and comes
|
|
281
|
+
back 4xx, because retrying it would be pointless.
|
|
282
|
+
"""
|
|
283
|
+
_require_seam(request)
|
|
284
|
+
current_user = _admit(request)
|
|
285
|
+
language = resolve_language(request)
|
|
286
|
+
tool = req.tool.strip()
|
|
287
|
+
if not tool:
|
|
288
|
+
raise http_error(422, "agent_seam.tool_required", language)
|
|
289
|
+
args = dict(req.args)
|
|
290
|
+
|
|
291
|
+
_guard(tool, args, language)
|
|
292
|
+
|
|
293
|
+
# After the mode-invariant denials, deliberately: they are the same
|
|
294
|
+
# answer for every role, so they need no user-table read to decide.
|
|
295
|
+
try:
|
|
296
|
+
dispatch_service.check_role(tool, current_user)
|
|
297
|
+
except PermissionError as exc:
|
|
298
|
+
# The shipped service raises HTTPException(403) here and that
|
|
299
|
+
# propagates untouched; a port that speaks Python's own
|
|
300
|
+
# authorization error gets the same status rather than a 500.
|
|
301
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
302
|
+
|
|
303
|
+
def _run() -> Any:
|
|
304
|
+
return dispatch_tool(
|
|
305
|
+
hooks,
|
|
306
|
+
tool,
|
|
307
|
+
args,
|
|
308
|
+
lambda: execute_tool(tool, args),
|
|
309
|
+
user_email=current_user,
|
|
310
|
+
workspace_id=req.workspace_id,
|
|
311
|
+
source="agent",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
# Off the loop: tool handlers open files, shell out, and write the
|
|
316
|
+
# graph, and this server has one event loop for every user (10.9.0).
|
|
317
|
+
result = await asyncio.to_thread(_run)
|
|
318
|
+
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
319
|
+
return {"error": str(exc)}
|
|
320
|
+
return {"result": result}
|
|
321
|
+
|
|
322
|
+
@router.post("/agent/change-proposal")
|
|
323
|
+
async def agent_change_proposal(
|
|
324
|
+
req: AgentChangeProposalRequest, request: Request
|
|
325
|
+
):
|
|
326
|
+
"""Ask the governor what should happen to this write.
|
|
327
|
+
|
|
328
|
+
The verdict is returned verbatim — ``{"decision": "allow_additive"}``
|
|
329
|
+
or ``{"decision": "proposed", "proposal": {...}}`` — because the Rust
|
|
330
|
+
loop has to act on the same facts the Review Center will show.
|
|
331
|
+
|
|
332
|
+
``review`` answers ``None`` for "I have nothing to say about this call:
|
|
333
|
+
fall through to the normal gates". Three different situations collapse
|
|
334
|
+
into that one ``None`` (not a governed tool; no proposal required; the
|
|
335
|
+
edit could not be computed deterministically) and the service does not
|
|
336
|
+
distinguish them, so neither does this payload: ``{"decision": "none"}``
|
|
337
|
+
and nothing invented about why.
|
|
338
|
+
|
|
339
|
+
No mode-invariant guard here, because nothing executes: staging a
|
|
340
|
+
proposal writes a review item, and the write itself still has to come
|
|
341
|
+
back through ``/agent/tool`` — where the guards are.
|
|
342
|
+
"""
|
|
343
|
+
_require_seam(request)
|
|
344
|
+
current_user = _admit(request)
|
|
345
|
+
language = resolve_language(request)
|
|
346
|
+
if change_proposals is None:
|
|
347
|
+
raise http_error(503, "agent_seam.proposals_unavailable", language)
|
|
348
|
+
tool = req.tool.strip()
|
|
349
|
+
if not tool:
|
|
350
|
+
raise http_error(422, "agent_seam.tool_required", language)
|
|
351
|
+
args = dict(req.args)
|
|
352
|
+
policy = (
|
|
353
|
+
dict(req.policy)
|
|
354
|
+
if req.policy is not None
|
|
355
|
+
else dict(dispatch_service.policy_for(tool, args))
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _review() -> Optional[Dict[str, Any]]:
|
|
359
|
+
return change_proposals.review(
|
|
360
|
+
tool,
|
|
361
|
+
args,
|
|
362
|
+
policy=policy,
|
|
363
|
+
user_email=current_user,
|
|
364
|
+
workspace_id=req.workspace_id,
|
|
365
|
+
conversation_id=req.conversation_id,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Staging reads the target, computes a diff, and writes a review item —
|
|
369
|
+
# all blocking I/O, none of it belonging on the event loop.
|
|
370
|
+
verdict = await asyncio.to_thread(_review)
|
|
371
|
+
if verdict is None:
|
|
372
|
+
return {"decision": "none"}
|
|
373
|
+
return verdict
|
|
374
|
+
|
|
375
|
+
return router
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = [
|
|
379
|
+
"MAX_MAX_TOKENS",
|
|
380
|
+
"MAX_TEMPERATURE",
|
|
381
|
+
"MIN_MAX_TOKENS",
|
|
382
|
+
"MIN_TEMPERATURE",
|
|
383
|
+
"SEAM_ENV_VAR",
|
|
384
|
+
"SEAM_RATE_BUCKET",
|
|
385
|
+
"AgentChangeProposalRequest",
|
|
386
|
+
"AgentLLMRequest",
|
|
387
|
+
"AgentToolRequest",
|
|
388
|
+
"create_agent_worker_seam_router",
|
|
389
|
+
]
|
package/latticeai/api/auth.py
CHANGED
|
@@ -13,6 +13,8 @@ from fastapi import APIRouter, Request
|
|
|
13
13
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
14
14
|
from pydantic import BaseModel
|
|
15
15
|
|
|
16
|
+
from latticeai.core.config import SSO_CALLBACK_PATH
|
|
17
|
+
from latticeai.core.http_origin import request_external_origin
|
|
16
18
|
from latticeai.core.messages import DEFAULT_LANGUAGE, http_error, resolve_language
|
|
17
19
|
from latticeai.core.oidc import (
|
|
18
20
|
OIDCValidationError,
|
|
@@ -56,6 +58,10 @@ class _SSOLoginState:
|
|
|
56
58
|
nonce: str
|
|
57
59
|
code_verifier: str
|
|
58
60
|
invite_authorized: bool
|
|
61
|
+
#: Exactly what was sent to ``authorize``. The token exchange must repeat
|
|
62
|
+
#: it byte for byte or the provider rejects the code, so it is carried
|
|
63
|
+
#: rather than recomputed from a second request that may differ.
|
|
64
|
+
redirect_uri: str = ""
|
|
59
65
|
|
|
60
66
|
|
|
61
67
|
# The nonce and PKCE verifier bind the callback/token to this login attempt.
|
|
@@ -88,6 +94,7 @@ def create_auth_router(
|
|
|
88
94
|
ensure_identity: Optional[Callable[[str, Dict], None]] = None,
|
|
89
95
|
invite_gate_enabled: bool = False,
|
|
90
96
|
invite_authorized: Optional[Callable[[Request], bool]] = None,
|
|
97
|
+
default_redirect_uri: Optional[str] = None,
|
|
91
98
|
verify_id_token: Callable[..., Dict] = _default_verify_id_token,
|
|
92
99
|
fetch_jwks: Callable[[str], Awaitable[Dict]] = _default_fetch_jwks,
|
|
93
100
|
) -> APIRouter:
|
|
@@ -169,6 +176,20 @@ def create_auth_router(
|
|
|
169
176
|
async def sso_config_endpoint():
|
|
170
177
|
return public_sso_config()
|
|
171
178
|
|
|
179
|
+
def _resolve_redirect_uri(request: Request, settings: Dict) -> str:
|
|
180
|
+
"""Where the provider should send the browser back to.
|
|
181
|
+
|
|
182
|
+
A configured URI is registered with the provider and is used verbatim.
|
|
183
|
+
Only the *built-in default* — which names the worker's own port, and is
|
|
184
|
+
therefore unreachable whenever a gateway fronts it — is replaced by the
|
|
185
|
+
front door this login actually arrived through.
|
|
186
|
+
"""
|
|
187
|
+
configured = str(settings.get("redirect_uri") or "")
|
|
188
|
+
if not default_redirect_uri or configured != default_redirect_uri:
|
|
189
|
+
return configured
|
|
190
|
+
origin = request_external_origin(request)
|
|
191
|
+
return f"{origin}{SSO_CALLBACK_PATH}" if origin else configured
|
|
192
|
+
|
|
172
193
|
@router.get("/auth/sso/login")
|
|
173
194
|
async def sso_login(request: Request):
|
|
174
195
|
lang = resolve_language(request)
|
|
@@ -193,16 +214,18 @@ def create_auth_router(
|
|
|
193
214
|
and invite_authorized(request)
|
|
194
215
|
)
|
|
195
216
|
)
|
|
217
|
+
redirect_uri = _resolve_redirect_uri(request, settings)
|
|
196
218
|
_sso_states[state] = _SSOLoginState(
|
|
197
219
|
issued_at=time.time(),
|
|
198
220
|
nonce=nonce,
|
|
199
221
|
code_verifier=code_verifier,
|
|
200
222
|
invite_authorized=invite_claim,
|
|
223
|
+
redirect_uri=redirect_uri,
|
|
201
224
|
)
|
|
202
225
|
params = urlencode({
|
|
203
226
|
"client_id": settings["client_id"],
|
|
204
227
|
"response_type": "code",
|
|
205
|
-
"redirect_uri":
|
|
228
|
+
"redirect_uri": redirect_uri,
|
|
206
229
|
"scope": settings.get("scopes") or "openid email profile",
|
|
207
230
|
"state": state,
|
|
208
231
|
"nonce": nonce,
|
|
@@ -228,7 +251,7 @@ def create_auth_router(
|
|
|
228
251
|
r = await c.post(discovery["token_endpoint"], data={
|
|
229
252
|
"grant_type": "authorization_code",
|
|
230
253
|
"code": code,
|
|
231
|
-
"redirect_uri": settings["redirect_uri"],
|
|
254
|
+
"redirect_uri": entry.redirect_uri or settings["redirect_uri"],
|
|
232
255
|
"client_id": settings["client_id"],
|
|
233
256
|
"client_secret": settings["client_secret"],
|
|
234
257
|
"code_verifier": entry.code_verifier,
|
package/latticeai/api/chat.py
CHANGED
|
@@ -42,7 +42,6 @@ from latticeai.api.chat_helpers import (
|
|
|
42
42
|
pair_user_history,
|
|
43
43
|
single_text_stream,
|
|
44
44
|
strip_generated_file_content,
|
|
45
|
-
workspace_scope_from_request,
|
|
46
45
|
)
|
|
47
46
|
from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
|
|
48
47
|
from latticeai.api.chat_hybrid import (
|
|
@@ -51,6 +50,7 @@ from latticeai.api.chat_hybrid import (
|
|
|
51
50
|
)
|
|
52
51
|
from latticeai.api.chat_intents import ChatIntentController
|
|
53
52
|
from latticeai.api.chat_stream import stream_chat
|
|
53
|
+
from latticeai.api.workspace_scope import requested_workspace
|
|
54
54
|
from latticeai.core.messages import DEFAULT_LANGUAGE, resolve_language, translate
|
|
55
55
|
from latticeai.core.project_sessions import ProjectSessionStore
|
|
56
56
|
from latticeai.core.run_store import AgentRunStore
|
|
@@ -84,7 +84,7 @@ __all__ = [
|
|
|
84
84
|
"is_clear_command",
|
|
85
85
|
"format_network_status",
|
|
86
86
|
"strip_generated_file_content",
|
|
87
|
-
"
|
|
87
|
+
"requested_workspace",
|
|
88
88
|
"single_text_stream",
|
|
89
89
|
]
|
|
90
90
|
|
|
@@ -272,7 +272,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
272
272
|
)
|
|
273
273
|
effective_email = authenticated_identity(current_user, req.user_email, resolve_language(request))
|
|
274
274
|
workspace_id = write_workspace(
|
|
275
|
-
|
|
275
|
+
requested_workspace(request),
|
|
276
276
|
current_user,
|
|
277
277
|
)
|
|
278
278
|
history_user = chat_service.history_user(
|
|
@@ -22,12 +22,9 @@ from latticeai.api.chat_contracts import (
|
|
|
22
22
|
AgentRequest,
|
|
23
23
|
AgentResumeRequest,
|
|
24
24
|
)
|
|
25
|
-
from latticeai.api.chat_helpers import
|
|
26
|
-
_LANG_HINT,
|
|
27
|
-
detect_language,
|
|
28
|
-
workspace_scope_from_request,
|
|
29
|
-
)
|
|
25
|
+
from latticeai.api.chat_helpers import _LANG_HINT, detect_language
|
|
30
26
|
from latticeai.api.chat_stream import agent_live_stream
|
|
27
|
+
from latticeai.api.workspace_scope import requested_workspace
|
|
31
28
|
from latticeai.core.agent import AgentRunContext, AgentState, normalize_plan
|
|
32
29
|
from latticeai.core.messages import resolve_language
|
|
33
30
|
from latticeai.core.quiet import quiet
|
|
@@ -268,14 +265,8 @@ class AgentHTTPController:
|
|
|
268
265
|
current_user = self.require_user(request)
|
|
269
266
|
self.enforce_rate_limit(current_user, "agent")
|
|
270
267
|
effective_email = self.authenticated_identity(current_user, req.user_email, resolve_language(request))
|
|
271
|
-
header_workspace = workspace_scope_from_request(request)
|
|
272
|
-
if req.workspace_id and header_workspace and req.workspace_id != header_workspace:
|
|
273
|
-
raise HTTPException(
|
|
274
|
-
status_code=403,
|
|
275
|
-
detail="workspace_id must match X-Workspace-Id.",
|
|
276
|
-
)
|
|
277
268
|
req.workspace_id = self.write_workspace(
|
|
278
|
-
req.workspace_id
|
|
269
|
+
requested_workspace(request, body_workspace=req.workspace_id),
|
|
279
270
|
current_user,
|
|
280
271
|
)
|
|
281
272
|
req.user_email = effective_email
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
"""Pure chat helpers: language/intent detection, file-action parsing,
|
|
2
|
-
network-status formatting,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
network-status formatting, and recent-context assembly. Split out of the chat
|
|
3
|
+
router module so create_chat_router keeps only request wiring. chat.py
|
|
4
|
+
re-imports every name, so external import sites (tests, app_factory) are
|
|
5
|
+
unaffected.
|
|
6
|
+
|
|
7
|
+
Workspace-scope extraction used to live here too, as a verbatim copy of the
|
|
8
|
+
header/query derivation. 11.5.2 deleted it: the callers now use
|
|
9
|
+
:func:`latticeai.api.workspace_scope.requested_workspace`, so a request that
|
|
10
|
+
names two different workspaces is refused instead of having one selector
|
|
11
|
+
silently win.
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
from __future__ import annotations
|
|
@@ -11,8 +17,6 @@ import json
|
|
|
11
17
|
import re
|
|
12
18
|
from typing import Any, AsyncIterator, Dict, List, Optional
|
|
13
19
|
|
|
14
|
-
from fastapi import Request
|
|
15
|
-
|
|
16
20
|
|
|
17
21
|
def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
18
22
|
"""Restrict history to one user's exchange.
|
|
@@ -406,13 +410,6 @@ def assess_answer_grounding(
|
|
|
406
410
|
}
|
|
407
411
|
|
|
408
412
|
|
|
409
|
-
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
410
|
-
header = request.headers.get("X-Workspace-Id")
|
|
411
|
-
if header and header.strip():
|
|
412
|
-
return header.strip()
|
|
413
|
-
query = request.query_params.get("workspace_id")
|
|
414
|
-
return query.strip() if query and query.strip() else None
|
|
415
|
-
|
|
416
413
|
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
417
414
|
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
418
415
|
yield "data: [DONE]\n\n"
|