ltcai 11.4.0 → 11.5.1

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 (86) hide show
  1. package/README.md +55 -44
  2. package/docs/CHANGELOG.md +48 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/docs/v11.4.0_RUST_FOUNDATION_PLAN.md +11 -6
  11. package/docs/v11.5.0_RUST_COMPLETE_PLAN.md +145 -0
  12. package/docs/v11.5.1_RUST_FULL_LOOP_PLAN.md +85 -0
  13. package/lattice_brain/__init__.py +1 -1
  14. package/lattice_brain/runtime/multi_agent.py +1 -1
  15. package/latticeai/__init__.py +1 -1
  16. package/latticeai/api/agent_worker_seam.py +389 -0
  17. package/latticeai/api/index_jobs.py +145 -0
  18. package/latticeai/core/legacy_compatibility.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/messages.py +40 -0
  21. package/latticeai/core/workspace_os_constants.py +1 -1
  22. package/latticeai/runtime/build_phases/features.py +37 -2
  23. package/latticeai/services/architecture_readiness.py +1 -1
  24. package/latticeai/services/product_readiness.py +1 -1
  25. package/package.json +1 -1
  26. package/scripts/check_current_release_docs.mjs +1 -1
  27. package/scripts/check_server_i18n.mjs +2 -0
  28. package/scripts/chunking_parity_corpus.py +449 -0
  29. package/scripts/generate_agent_loop_fixtures.py +907 -0
  30. package/scripts/generate_agent_parity_fixtures.py +752 -0
  31. package/scripts/generate_chunking_parity_fixtures.py +259 -0
  32. package/scripts/generate_rust_parity_fixtures.py +541 -103
  33. package/scripts/parity_fixture_corpus_docgen.py +341 -0
  34. package/scripts/release_screen_claims.json +21 -0
  35. package/src-tauri/Cargo.lock +53 -4
  36. package/src-tauri/Cargo.toml +11 -4
  37. package/src-tauri/src/backend.rs +251 -140
  38. package/src-tauri/src/main.rs +16 -4
  39. package/src-tauri/src/topology.rs +356 -0
  40. package/src-tauri/tauri.conf.json +1 -1
  41. package/static/app/asset-manifest.json +41 -41
  42. package/static/app/assets/{Act-yYpYnn0v.js → Act-Drs_jd-O.js} +1 -1
  43. package/static/app/assets/{AdminConsole-DL3Cr5pL.js → AdminConsole-BqNx5oF6.js} +1 -1
  44. package/static/app/assets/{Brain-C1HBN0Wf.js → Brain-C7bdNmz-.js} +1 -1
  45. package/static/app/assets/{BrainHome-DoXRhUUC.js → BrainHome-DcTnINcI.js} +1 -1
  46. package/static/app/assets/{BrainSignals-6yR6ir5t.js → BrainSignals-Df1MRIPD.js} +1 -1
  47. package/static/app/assets/{Capture-CFIRsFNE.js → Capture-Dgsxvnbx.js} +1 -1
  48. package/static/app/assets/{Chronicle-BZbEgiwN.js → Chronicle-BrkTxJK1.js} +1 -1
  49. package/static/app/assets/{CommandPalette-D2pMxC2I.js → CommandPalette-DDziw7Oj.js} +1 -1
  50. package/static/app/assets/{Library-DwO3yZST.js → Library-CYxeuraA.js} +1 -1
  51. package/static/app/assets/{LivingBrain-Jn1GK0-S.js → LivingBrain-CNp6mvCm.js} +1 -1
  52. package/static/app/assets/{ProductFlow-B-w1R4Oo.js → ProductFlow-BVsowu3Z.js} +1 -1
  53. package/static/app/assets/{ReviewCard-6B27X8Vg.js → ReviewCard-D1LbOfJS.js} +1 -1
  54. package/static/app/assets/{System-DW8F-2xL.js → System-DcRAq7wj.js} +1 -1
  55. package/static/app/assets/arrow-left-BHYmOTWU.js +1 -0
  56. package/static/app/assets/{bot-IM_E_Y12.js → bot-BK2xQ9mN.js} +1 -1
  57. package/static/app/assets/{brain-Ci1CkWjM.js → brain-BZNztFBb.js} +1 -1
  58. package/static/app/assets/{button-COwyqfHM.js → button-CjueubVZ.js} +1 -1
  59. package/static/app/assets/circle-check-CLeWJr67.js +1 -0
  60. package/static/app/assets/{circle-pause-DEM4A1Y5.js → circle-pause-HRjSmpyY.js} +1 -1
  61. package/static/app/assets/{circle-play-C9djDuLd.js → circle-play-CMcKDVwu.js} +1 -1
  62. package/static/app/assets/{cpu-DFdo1gw-.js → cpu-B_MyTfYr.js} +1 -1
  63. package/static/app/assets/{download-SnJL6oqk.js → download-CTy0EByV.js} +1 -1
  64. package/static/app/assets/{folder-open-CqZeDkjE.js → folder-open-DF6bLhTg.js} +1 -1
  65. package/static/app/assets/{hard-drive-j1jJXYYf.js → hard-drive-CpWHG77C.js} +1 -1
  66. package/static/app/assets/{index-_u5iUHDr.js → index-Dd6abJHX.js} +3 -3
  67. package/static/app/assets/index-DxmOfNRi.css +2 -0
  68. package/static/app/assets/{input-B0lPdRQZ.js → input-bVgIRN3s.js} +1 -1
  69. package/static/app/assets/{link-2-CoFbooHS.js → link-2-bg6CG5Vk.js} +1 -1
  70. package/static/app/assets/{permissionCopy-BsyLxtao.js → permissionCopy-DFTf1HDZ.js} +1 -1
  71. package/static/app/assets/{primitives-DEbN-d6p.js → primitives-D7D-sQ3T.js} +1 -1
  72. package/static/app/assets/search-BgSVX6NG.js +1 -0
  73. package/static/app/assets/{share-2-CVtZ_ewX.js → share-2-BLzo7U4L.js} +1 -1
  74. package/static/app/assets/{shield-alert-CBi2GNWM.js → shield-alert-BmngqnsJ.js} +1 -1
  75. package/static/app/assets/{textarea-DNMpB5ih.js → textarea-DOSlAQQ3.js} +1 -1
  76. package/static/app/assets/{useFocusTrap-C83t3GXF.js → useFocusTrap-pZhgeee4.js} +1 -1
  77. package/static/app/assets/{useMutation-DtbJDoyz.js → useMutation-BMDwNk4I.js} +1 -1
  78. package/static/app/assets/{useQuery-Dcp1OChy.js → useQuery-CSjKttRo.js} +1 -1
  79. package/static/app/assets/{utils-BlZr7Pd4.js → utils-D3u_yv7B.js} +1 -1
  80. package/static/app/assets/{workspace-jJY4RuAV.js → workspace-H3bMjYXC.js} +1 -1
  81. package/static/app/index.html +4 -4
  82. package/static/sw.js +1 -1
  83. package/static/app/assets/arrow-left-DXvKg9U6.js +0 -1
  84. package/static/app/assets/circle-check-DfInj-qD.js +0 -1
  85. package/static/app/assets/index-BLPb5lmE.css +0 -2
  86. package/static/app/assets/search-BybIWPNd.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
+ ]
@@ -0,0 +1,145 @@
1
+ """Index jobs API (v11.5.0, plan §3a) — the trigger the embed queue never had.
2
+
3
+ ``vector_jobs`` has been a durable backlog since v11.1.0: an ingest whose
4
+ inline embedding fails lands anyway, the node is queued, and
5
+ ``VectorEmbedQueue.tick`` will embed it *when someone runs a tick*. Nobody did.
6
+ The only drain in the product was
7
+ :meth:`~lattice_brain.ingestion.IngestionPipeline.drain_vector_queue`, callable
8
+ from a test or a REPL and from nothing that runs on its own — which is what
9
+ FEATURE_STATUS.md:179-185 says out loud.
10
+
11
+ This router is the missing half: an HTTP surface a scheduler can call.
12
+
13
+ * ``POST /api/index/drain`` — run one tick now, answer with what it did and
14
+ what is left.
15
+ * ``GET /api/index/queue`` — the backlog counts, read-only.
16
+
17
+ Their two siblings under the same prefix live in ``search.py``
18
+ (``/api/index/status``, ``/api/index/rebuild``): those answer *is the index
19
+ complete* and *rebuild it from scratch*, which is the operator's hammer. This
20
+ pair is the small, repeatable step a timer can take every minute.
21
+
22
+ Two honest boundaries, stated here so the payloads are not read as more than
23
+ they are:
24
+
25
+ * **The backlog is machine-wide.** One SQLite queue serves every workspace, so
26
+ a drain embeds whatever is owed regardless of who asked, and the counts are
27
+ totals for this Brain. The workspace gate still runs on both paths — it is
28
+ the authorization check, not a filter — and the drain payload says
29
+ ``scope: "machine"`` rather than implying a scoped number.
30
+ * **Draining is not indexing.** The tick delegates to the store's own indexer;
31
+ a node that fails goes back to ``pending`` until its retry budget runs out.
32
+ The response reports ``claimed``/``indexed``/``retried``/``failed`` verbatim
33
+ from the queue instead of summarising them into a success flag.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ from typing import Any, Callable, Dict, Optional
40
+
41
+ from fastapi import APIRouter, Request
42
+ from pydantic import BaseModel
43
+
44
+ from lattice_brain.graph.vector_index import DEFAULT_TICK_LIMIT, VECTOR_JOB_STATUSES
45
+ from latticeai.core.messages import http_error, resolve_language
46
+
47
+ #: Bounds on one drain. The floor keeps a caller from asking for a no-op tick
48
+ #: that still claims the queue's lock; the ceiling keeps one HTTP request from
49
+ #: turning into an unbounded embedding run behind a client that will time out.
50
+ MIN_DRAIN_LIMIT = 1
51
+ MAX_DRAIN_LIMIT = 100
52
+
53
+
54
+ class DrainRequest(BaseModel):
55
+ """How many queued nodes one drain may claim.
56
+
57
+ Omitted (or a body-less POST) means the queue's own tick size, so a caller
58
+ with no opinion inherits the number the queue was designed around.
59
+ """
60
+
61
+ limit: Optional[int] = None
62
+
63
+
64
+ def create_index_jobs_router(
65
+ *,
66
+ pipeline: Any,
67
+ knowledge_graph: Any,
68
+ require_user: Callable[[Request], Any],
69
+ gate_read: Callable[[Request], Optional[str]],
70
+ gate_write: Callable[[Request], Optional[str]],
71
+ ) -> APIRouter:
72
+ router = APIRouter()
73
+
74
+ def _require_pipeline(request: Request) -> Any:
75
+ """The pipeline, or 503 — a drain without ingestion is not a 500."""
76
+ if pipeline is None or not pipeline.available():
77
+ raise http_error(503, "capture.ingestion_disabled", resolve_language(request))
78
+ return pipeline
79
+
80
+ def _queue_state() -> Dict[str, Any]:
81
+ """Backlog counts, or an explicit "not tracked" — never a fake zero.
82
+
83
+ A store without a durable queue reports ``available: false`` alongside
84
+ its zeros, because "0 pending" and "nothing is counting" are different
85
+ answers and a scheduler polling this would otherwise read the second as
86
+ the first.
87
+ """
88
+ queue = getattr(knowledge_graph, "vector_queue", None)
89
+ if queue is None or not queue.available:
90
+ return {
91
+ "available": False,
92
+ "counts": dict.fromkeys(VECTOR_JOB_STATUSES, 0),
93
+ "pending": 0,
94
+ }
95
+ return {
96
+ "available": True,
97
+ "counts": dict(queue.snapshot()),
98
+ "pending": int(queue.pending_count()),
99
+ }
100
+
101
+ @router.post("/api/index/drain")
102
+ async def drain_index_queue(request: Request, req: Optional[DrainRequest] = None):
103
+ """Run one background-embedding tick and report what it did.
104
+
105
+ Off the event loop: the tick opens SQLite and calls the embedder, and
106
+ this server has one loop for every user (10.9.0).
107
+ """
108
+ require_user(request)
109
+ gate_write(request)
110
+ active = _require_pipeline(request)
111
+ requested = req.limit if req is not None else None
112
+ limit = DEFAULT_TICK_LIMIT if requested is None else requested
113
+ if limit < MIN_DRAIN_LIMIT or limit > MAX_DRAIN_LIMIT:
114
+ raise http_error(
115
+ 422,
116
+ "index.limit_out_of_range",
117
+ resolve_language(request),
118
+ min=MIN_DRAIN_LIMIT,
119
+ max=MAX_DRAIN_LIMIT,
120
+ )
121
+ tick = await asyncio.to_thread(active.drain_vector_queue, limit)
122
+ return {
123
+ **dict(tick),
124
+ "limit": limit,
125
+ "scope": "machine",
126
+ "queue": _queue_state(),
127
+ }
128
+
129
+ @router.get("/api/index/queue")
130
+ async def index_queue(request: Request):
131
+ """The embed backlog, counted. Reads nothing else and writes nothing."""
132
+ require_user(request)
133
+ gate_read(request)
134
+ _require_pipeline(request)
135
+ return _queue_state()
136
+
137
+ return router
138
+
139
+
140
+ __all__ = [
141
+ "MAX_DRAIN_LIMIT",
142
+ "MIN_DRAIN_LIMIT",
143
+ "DrainRequest",
144
+ "create_index_jobs_router",
145
+ ]
@@ -13,7 +13,7 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
- LEGACY_COMPATIBILITY_VERSION = "11.4.0"
16
+ LEGACY_COMPATIBILITY_VERSION = "11.5.1"
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
- MARKETPLACE_VERSION = "11.4.0"
13
+ MARKETPLACE_VERSION = "11.5.1"
14
14
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
15
15
 
16
16
 
@@ -531,6 +531,46 @@ MESSAGES: Dict[str, Dict[str, str]] = {
531
531
  "ko": "시점을 읽을 수 없습니다. 2026-08-11T09:00:00 처럼 적어 주세요.",
532
532
  "en": "That moment could not be read. Write it like 2026-08-11T09:00:00.",
533
533
  },
534
+ # ── index jobs ──────────────────────────────────────────────────────
535
+ "index.limit_out_of_range": {
536
+ "ko": "한 번에 처리할 개수는 {min}에서 {max} 사이여야 합니다.",
537
+ "en": "Ask for between {min} and {max} items in one pass.",
538
+ },
539
+ # ── AI-Worker seam (the Rust loop's three calls back into Python) ────
540
+ "agent_seam.disabled": {
541
+ "ko": "워커 시임이 꺼져 있습니다. 호스트가 직접 띄운 워커에서만 열립니다.",
542
+ "en": "The worker seam is off. It opens only in a worker the host started itself.",
543
+ },
544
+ "agent_seam.message_required": {
545
+ "ko": "모델에 보낼 내용을 적어주세요.",
546
+ "en": "Write the message to send to the model.",
547
+ },
548
+ "agent_seam.max_tokens_out_of_range": {
549
+ "ko": "한 번에 만들 길이는 {min}에서 {max} 사이여야 합니다.",
550
+ "en": "Ask for between {min} and {max} tokens in one completion.",
551
+ },
552
+ "agent_seam.temperature_out_of_range": {
553
+ "ko": "온도는 {min}에서 {max} 사이여야 합니다.",
554
+ "en": "Temperature has to be between {min} and {max}.",
555
+ },
556
+ "agent_seam.tool_required": {
557
+ "ko": "실행할 도구 이름이 필요합니다.",
558
+ "en": "A tool name to run is required.",
559
+ },
560
+ "agent_seam.tool_blocked": {
561
+ "ko": "'{tool}' 도구는 어떤 모드에서도 차단됩니다 ({reason}).",
562
+ "en": "'{tool}' is blocked in every mode ({reason}).",
563
+ },
564
+ "agent_seam.tool_fail_closed": {
565
+ "ko": "'{tool}' 도구는 기존 내용을 바꾸지만 검토할 수 있는 제안으로 "
566
+ "만들 수 없어 차단했습니다 ({reason}).",
567
+ "en": "'{tool}' would change existing content but cannot be staged as a "
568
+ "reviewable proposal, so it is blocked ({reason}).",
569
+ },
570
+ "agent_seam.proposals_unavailable": {
571
+ "ko": "변경 제안 서비스가 연결되어 있지 않습니다.",
572
+ "en": "The change proposal service is not connected.",
573
+ },
534
574
  # ── MCP / setup ─────────────────────────────────────────────────────
535
575
  "mcp.connector_not_found": {
536
576
  "ko": "커넥터를 찾을 수 없습니다.",
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Dict
12
12
 
13
- WORKSPACE_OS_VERSION = "11.4.0"
13
+ WORKSPACE_OS_VERSION = "11.5.1"
14
14
 
15
15
  # Workspace types separate single-user Personal workspaces from shared
16
16
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -16,6 +16,7 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
16
16
  """Workspace platform, automation, review queue, command centre, proposals."""
17
17
  ctx.enter("platform_features")
18
18
 
19
+ from latticeai.api.agent_worker_seam import create_agent_worker_seam_router
19
20
  from latticeai.api.agents import create_agents_router
20
21
  from latticeai.api.automation_intelligence import (
21
22
  create_automation_intelligence_router,
@@ -25,6 +26,7 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
25
26
  from latticeai.api.command_center import create_command_center_router
26
27
  from latticeai.api.evidence_actions import create_evidence_actions_router
27
28
  from latticeai.api.funnel_metrics import create_funnel_metrics_router
29
+ from latticeai.api.index_jobs import create_index_jobs_router
28
30
  from latticeai.api.marketplace import create_marketplace_router
29
31
  from latticeai.api.plugins import create_plugins_router
30
32
  from latticeai.api.project_sessions import create_project_sessions_router
@@ -47,9 +49,12 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
47
49
  from latticeai.services.chronicle import ChronicleService
48
50
  from latticeai.services.command_center import CommandCenterService
49
51
  from latticeai.services.evidence_actions import EvidenceActionService
50
- from latticeai.services.tool_dispatch import get_tool_permission
52
+ from latticeai.services.tool_dispatch import (
53
+ DEFAULT_TOOL_DISPATCH_SERVICE,
54
+ get_tool_permission,
55
+ )
51
56
  from latticeai.services.voice_capture import VoiceCaptureService
52
- from latticeai.tools import resolve_workspace_path
57
+ from latticeai.tools import execute_tool, resolve_workspace_path
53
58
 
54
59
  # v2 Agentic Workspace Platform: cross-system wiring.
55
60
  platform_automation_runtime = build_platform_automation_runtime(
@@ -176,6 +181,19 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
176
181
  )
177
182
  )
178
183
 
184
+ # Index jobs (v11.5.0): the embed backlog has been durable since 11.1.0 and
185
+ # had no trigger — the only drain was a pipeline method no scheduler could
186
+ # reach. This is the HTTP surface lattice-jobs ticks.
187
+ ctx.app.include_router(
188
+ create_index_jobs_router(
189
+ pipeline=ctx.INGESTION_PIPELINE if ctx.ENABLE_GRAPH else None,
190
+ knowledge_graph=ctx.KNOWLEDGE_GRAPH if ctx.ENABLE_GRAPH else None,
191
+ require_user=ctx.require_user,
192
+ gate_read=ctx.PLATFORM.gate_read,
193
+ gate_write=ctx.PLATFORM.gate_write,
194
+ )
195
+ )
196
+
179
197
  ctx.set(
180
198
  CHANGE_PROPOSALS=ChangeProposalService(
181
199
  review_queue=ctx.REVIEW_QUEUE,
@@ -196,6 +214,23 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
196
214
  )
197
215
  )
198
216
 
217
+ # AI-Worker seam (v11.5.1, plan §Y1): the three calls the Rust agent loop
218
+ # makes back into Python once orchestration lives in ``lattice-agent``.
219
+ # Registered here because it needs the change governor above; the two
220
+ # side-effecting routes stay behind ``LATTICEAI_AGENT_TOOL_SEAM=1``, which
221
+ # only lattice-host injects into a worker it started.
222
+ ctx.app.include_router(
223
+ create_agent_worker_seam_router(
224
+ model_router=ctx.model_router,
225
+ dispatch_service=DEFAULT_TOOL_DISPATCH_SERVICE,
226
+ execute_tool=execute_tool,
227
+ hooks=ctx.HOOKS_REGISTRY,
228
+ change_proposals=ctx.CHANGE_PROPOSALS,
229
+ require_user=ctx.require_user,
230
+ enforce_rate_limit=ctx.enforce_rate_limit,
231
+ )
232
+ )
233
+
199
234
  # Evidence → action (v9.9.6): an answer's citations become ready-to-send,
200
235
  # evidence-scoped follow-ups. Deterministic composition only — execution
201
236
  # stays on the chat/file-generation path.