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.
Files changed (182) hide show
  1. package/README.md +86 -47
  2. package/docs/CHANGELOG.md +76 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/HYBRID_CLOUD_KG_STREAMING.md +8 -4
  6. package/docs/MULTI_AGENT_RUNTIME.md +1 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +1 -1
  11. package/docs/WORKFLOW_DESIGNER.md +1 -1
  12. package/docs/architecture.md +4 -4
  13. package/docs/kg-schema.md +1 -1
  14. package/docs/v11.5.1_RUST_FULL_LOOP_PLAN.md +85 -0
  15. package/docs/v11.5.2_TIGHT_SHIP_PLAN.md +144 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/graph/proactive.py +1 -1
  18. package/lattice_brain/graph/projection/v2_schema.py +0 -28
  19. package/lattice_brain/graph/vector_index/base.py +0 -3
  20. package/lattice_brain/graph/vector_index/brute_force.py +0 -4
  21. package/lattice_brain/graph/vector_index/hnsw.py +0 -3
  22. package/lattice_brain/graph/vector_index/quantized.py +0 -3
  23. package/lattice_brain/ingestion_jobs.py +0 -3
  24. package/lattice_brain/memory.py +0 -27
  25. package/lattice_brain/portability/sharing.py +0 -4
  26. package/lattice_brain/quality.py +0 -49
  27. package/lattice_brain/runtime/agent_runtime.py +1 -1
  28. package/lattice_brain/runtime/hooks.py +0 -13
  29. package/lattice_brain/runtime/multi_agent.py +1 -1
  30. package/lattice_brain/sealed_box.py +0 -4
  31. package/latticeai/__init__.py +1 -1
  32. package/latticeai/api/admin.py +12 -12
  33. package/latticeai/api/agent_worker_seam.py +389 -0
  34. package/latticeai/api/auth.py +25 -2
  35. package/latticeai/api/chat.py +3 -3
  36. package/latticeai/api/chat_agent_http.py +3 -12
  37. package/latticeai/api/chat_helpers.py +10 -13
  38. package/latticeai/api/computer_use.py +31 -42
  39. package/latticeai/api/health.py +21 -1
  40. package/latticeai/api/local_files.py +14 -0
  41. package/latticeai/api/permissions.py +38 -12
  42. package/latticeai/api/search.py +24 -0
  43. package/latticeai/api/static_routes.py +4 -1
  44. package/latticeai/cli/runtime.py +7 -3
  45. package/latticeai/core/config.py +47 -3
  46. package/latticeai/core/csrf.py +28 -2
  47. package/latticeai/core/embedding_providers/__init__.py +3 -5
  48. package/latticeai/core/embedding_providers/base.py +1 -1
  49. package/latticeai/core/embedding_providers/text.py +1 -1
  50. package/latticeai/core/enterprise.py +1 -4
  51. package/latticeai/core/http_origin.py +146 -0
  52. package/latticeai/core/invitations.py +3 -2
  53. package/latticeai/core/io_utils.py +2 -11
  54. package/latticeai/core/legacy_compatibility.py +1 -1
  55. package/latticeai/core/marketplace.py +1 -1
  56. package/latticeai/core/messages.py +40 -0
  57. package/latticeai/core/model_compat.py +2 -8
  58. package/latticeai/core/module_probe.py +37 -0
  59. package/latticeai/core/run_explain.py +5 -2
  60. package/latticeai/core/run_store.py +2 -2
  61. package/latticeai/core/security.py +13 -0
  62. package/latticeai/core/sessions.py +3 -2
  63. package/latticeai/core/sse.py +25 -0
  64. package/latticeai/core/users.py +0 -9
  65. package/latticeai/core/workspace_graph_trace.py +2 -8
  66. package/latticeai/core/workspace_os.py +0 -10
  67. package/latticeai/core/workspace_os_constants.py +1 -1
  68. package/latticeai/core/workspace_os_utils.py +34 -0
  69. package/latticeai/core/workspace_review_items.py +2 -8
  70. package/latticeai/core/workspace_runs.py +2 -8
  71. package/latticeai/core/workspace_skills.py +2 -7
  72. package/latticeai/models/router/documents.py +0 -35
  73. package/latticeai/models/router/registry.py +0 -4
  74. package/latticeai/runtime/access_runtime.py +16 -4
  75. package/latticeai/runtime/build_phases/features.py +25 -2
  76. package/latticeai/runtime/build_phases/web.py +2 -0
  77. package/latticeai/runtime/feature_toggle_wiring.py +12 -18
  78. package/latticeai/runtime/network_boundary_wiring.py +8 -19
  79. package/latticeai/runtime/permission_mode_wiring.py +6 -13
  80. package/latticeai/runtime/router_registration.py +4 -0
  81. package/latticeai/runtime/runtime_context.py +1 -13
  82. package/latticeai/runtime/service_singletons.py +55 -0
  83. package/latticeai/services/architecture_readiness.py +1 -1
  84. package/latticeai/services/brain_intelligence/proposals.py +0 -5
  85. package/latticeai/services/change_proposals.py +2 -36
  86. package/latticeai/services/chronicle.py +4 -6
  87. package/latticeai/services/command_center.py +4 -4
  88. package/latticeai/services/evidence_actions.py +5 -2
  89. package/latticeai/services/hybrid_chat.py +2 -2
  90. package/latticeai/services/hybrid_policy.py +8 -57
  91. package/latticeai/services/mode_store.py +132 -0
  92. package/latticeai/services/model_capability_registry.py +0 -15
  93. package/latticeai/services/model_catalog.py +0 -7
  94. package/latticeai/services/model_loading.py +3 -2
  95. package/latticeai/services/model_runtime/__init__.py +0 -57
  96. package/latticeai/services/model_runtime/engines.py +27 -128
  97. package/latticeai/services/model_runtime/loading.py +2 -2
  98. package/latticeai/services/network_boundary_service.py +7 -44
  99. package/latticeai/services/permission_mode_service.py +8 -56
  100. package/latticeai/services/product_readiness.py +1 -1
  101. package/latticeai/services/setup_detection.py +67 -1
  102. package/latticeai/services/tool_dispatch.py +0 -4
  103. package/latticeai/services/upload_service.py +7 -20
  104. package/latticeai/services/workspace_service.py +0 -6
  105. package/latticeai/setup/auto_setup.py +15 -26
  106. package/latticeai/setup/wizard/catalog.py +5 -2
  107. package/latticeai/setup/wizard/detect.py +6 -27
  108. package/latticeai/setup/wizard/paths.py +2 -5
  109. package/latticeai/setup/wizard/plans.py +2 -2
  110. package/package.json +2 -4
  111. package/scripts/bump_version.py +14 -1
  112. package/scripts/check_current_release_docs.mjs +1 -1
  113. package/scripts/check_legacy_debt.mjs +1 -1
  114. package/scripts/check_server_i18n.mjs +1 -0
  115. package/scripts/generate_agent_loop_fixtures.py +994 -0
  116. package/scripts/generate_rust_parity_fixtures.py +72 -161
  117. package/scripts/parity_fixture_corpus_context.py +162 -0
  118. package/scripts/parity_fixture_corpus_docgen.py +341 -0
  119. package/scripts/release_screen_claims.json +20 -0
  120. package/src-tauri/Cargo.lock +13 -7
  121. package/src-tauri/Cargo.toml +1 -1
  122. package/src-tauri/src/backend.rs +76 -2
  123. package/src-tauri/src/main.rs +13 -3
  124. package/src-tauri/tauri.conf.json +2 -2
  125. package/static/app/asset-manifest.json +41 -41
  126. package/static/app/assets/{Act-CWnxSCgN.js → Act-DcQizkl1.js} +1 -1
  127. package/static/app/assets/{AdminConsole-BEQYU6kF.js → AdminConsole-cf4npybT.js} +1 -1
  128. package/static/app/assets/{Brain-DWu1BhFg.js → Brain-3VCSHFcn.js} +1 -1
  129. package/static/app/assets/{BrainHome-95Hilr9R.js → BrainHome-Qm8eaztx.js} +1 -1
  130. package/static/app/assets/{BrainSignals-QdeqCpAF.js → BrainSignals-DS9BtKOW.js} +1 -1
  131. package/static/app/assets/{Capture-BHpCxnzb.js → Capture-DiQ219jW.js} +1 -1
  132. package/static/app/assets/{Chronicle-B4xYKoed.js → Chronicle-BGvuAchH.js} +1 -1
  133. package/static/app/assets/{CommandPalette-BVXnttSz.js → CommandPalette-Bqhm0Urn.js} +1 -1
  134. package/static/app/assets/{Library-DgYcHome.js → Library-BV6NnF0a.js} +1 -1
  135. package/static/app/assets/{LivingBrain-CrJLDbf7.js → LivingBrain-GzenJchP.js} +1 -1
  136. package/static/app/assets/{ProductFlow-DFlScKoJ.js → ProductFlow-DEP6-vML.js} +1 -1
  137. package/static/app/assets/{ReviewCard-Cy5f48Pj.js → ReviewCard-CNZ7XjWG.js} +1 -1
  138. package/static/app/assets/{System-NF8IfhTa.js → System-CieofHQa.js} +1 -1
  139. package/static/app/assets/arrow-left-kfsrk0mv.js +1 -0
  140. package/static/app/assets/{bot-CucuhLhm.js → bot-B_K1Tdmw.js} +1 -1
  141. package/static/app/assets/{brain-BBnSryW_.js → brain-DWyaV1L1.js} +1 -1
  142. package/static/app/assets/{button-C2GUj2Ai.js → button-aTn4s84A.js} +1 -1
  143. package/static/app/assets/circle-check-qqLug9nU.js +1 -0
  144. package/static/app/assets/{circle-pause-CbkWzBmG.js → circle-pause-xKgeGXkT.js} +1 -1
  145. package/static/app/assets/{circle-play-7lEaqHdJ.js → circle-play-DkT6tYPX.js} +1 -1
  146. package/static/app/assets/{cpu-DAlCXlIy.js → cpu-85xYObUC.js} +1 -1
  147. package/static/app/assets/{download-RNhuuJwh.js → download-B5Fm7YXo.js} +1 -1
  148. package/static/app/assets/{folder-open-CLW4odzM.js → folder-open-kk2Xa52u.js} +1 -1
  149. package/static/app/assets/{hard-drive-NKEiDIAJ.js → hard-drive-DkA3zBW_.js} +1 -1
  150. package/static/app/assets/{index-DMurvUuR.js → index-BMPdTmlY.js} +3 -3
  151. package/static/app/assets/index-DxmOfNRi.css +2 -0
  152. package/static/app/assets/{input-D2UhPC1X.js → input-B0nRf2jO.js} +1 -1
  153. package/static/app/assets/{link-2-6amKbP_P.js → link-2-Dwb4gnTc.js} +1 -1
  154. package/static/app/assets/{permissionCopy-Cu9TZtdR.js → permissionCopy-CQDUBrOZ.js} +1 -1
  155. package/static/app/assets/{primitives-gPsccucr.js → primitives-SNp0LRJz.js} +1 -1
  156. package/static/app/assets/search-BcHqkjoy.js +1 -0
  157. package/static/app/assets/{share-2-Bau7KkPq.js → share-2-BsrxFglO.js} +1 -1
  158. package/static/app/assets/{shield-alert-BufNYypi.js → shield-alert-5BStfp2_.js} +1 -1
  159. package/static/app/assets/{textarea-BQnVWhYs.js → textarea-Cg8IUA-k.js} +1 -1
  160. package/static/app/assets/{useFocusTrap-B3_w60si.js → useFocusTrap-CYKvE46M.js} +1 -1
  161. package/static/app/assets/{useMutation-BHhCflT6.js → useMutation-CSn9t1op.js} +1 -1
  162. package/static/app/assets/{useQuery-rBWfI-5t.js → useQuery-CY2OI2uy.js} +1 -1
  163. package/static/app/assets/{utils-V_5-wxr5.js → utils-Ddol2RWD.js} +1 -1
  164. package/static/app/assets/{workspace-K1zjYUHj.js → workspace-BqDwOz_p.js} +1 -1
  165. package/static/app/index.html +4 -4
  166. package/static/sw.js +1 -1
  167. package/desktop/electron/README.md +0 -9
  168. package/desktop/electron/main.cjs +0 -58
  169. package/desktop/electron/preload.cjs +0 -5
  170. package/latticeai/core/graph_curator.py +0 -11
  171. package/latticeai/core/hooks.py +0 -11
  172. package/latticeai/core/local_embeddings.py +0 -104
  173. package/latticeai/core/multi_agent.py +0 -11
  174. package/latticeai/core/workflow_engine.py +0 -11
  175. package/latticeai/services/ingestion.py +0 -11
  176. package/latticeai/services/kg_portability.py +0 -11
  177. package/latticeai/services/multimodal_streaming.py +0 -129
  178. package/scripts/measure_brain_home_fill.mjs +0 -141
  179. package/static/app/assets/arrow-left-DwkSYrjR.js +0 -1
  180. package/static/app/assets/circle-check-CxOVPwYq.js +0 -1
  181. package/static/app/assets/index-BLPb5lmE.css +0 -2
  182. 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
+ ]
@@ -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": settings["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,
@@ -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
- "workspace_scope_from_request",
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
- workspace_scope_from_request(request),
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 or header_workspace,
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, workspace-scope extraction, and recent-context
3
- assembly. Split out of the chat router module so create_chat_router keeps
4
- only request wiring. chat.py re-imports every name, so external import
5
- sites (tests, app_factory) are unaffected.
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"