ltcai 11.5.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.
- package/README.md +25 -18
- package/docs/CHANGELOG.md +22 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.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/kg-schema.md +1 -1
- package/docs/v11.5.1_RUST_FULL_LOOP_PLAN.md +85 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agent_worker_seam.py +389 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +35 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/runtime/build_phases/features.py +23 -2
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/generate_agent_loop_fixtures.py +907 -0
- package/scripts/generate_rust_parity_fixtures.py +70 -67
- package/scripts/parity_fixture_corpus_docgen.py +341 -0
- package/scripts/release_screen_claims.json +10 -0
- package/src-tauri/Cargo.lock +13 -7
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +41 -41
- package/static/app/assets/{Act-CWnxSCgN.js → Act-Drs_jd-O.js} +1 -1
- package/static/app/assets/{AdminConsole-BEQYU6kF.js → AdminConsole-BqNx5oF6.js} +1 -1
- package/static/app/assets/{Brain-DWu1BhFg.js → Brain-C7bdNmz-.js} +1 -1
- package/static/app/assets/{BrainHome-95Hilr9R.js → BrainHome-DcTnINcI.js} +1 -1
- package/static/app/assets/{BrainSignals-QdeqCpAF.js → BrainSignals-Df1MRIPD.js} +1 -1
- package/static/app/assets/{Capture-BHpCxnzb.js → Capture-Dgsxvnbx.js} +1 -1
- package/static/app/assets/{Chronicle-B4xYKoed.js → Chronicle-BrkTxJK1.js} +1 -1
- package/static/app/assets/{CommandPalette-BVXnttSz.js → CommandPalette-DDziw7Oj.js} +1 -1
- package/static/app/assets/{Library-DgYcHome.js → Library-CYxeuraA.js} +1 -1
- package/static/app/assets/{LivingBrain-CrJLDbf7.js → LivingBrain-CNp6mvCm.js} +1 -1
- package/static/app/assets/{ProductFlow-DFlScKoJ.js → ProductFlow-BVsowu3Z.js} +1 -1
- package/static/app/assets/{ReviewCard-Cy5f48Pj.js → ReviewCard-D1LbOfJS.js} +1 -1
- package/static/app/assets/{System-NF8IfhTa.js → System-DcRAq7wj.js} +1 -1
- package/static/app/assets/arrow-left-BHYmOTWU.js +1 -0
- package/static/app/assets/{bot-CucuhLhm.js → bot-BK2xQ9mN.js} +1 -1
- package/static/app/assets/{brain-BBnSryW_.js → brain-BZNztFBb.js} +1 -1
- package/static/app/assets/{button-C2GUj2Ai.js → button-CjueubVZ.js} +1 -1
- package/static/app/assets/circle-check-CLeWJr67.js +1 -0
- package/static/app/assets/{circle-pause-CbkWzBmG.js → circle-pause-HRjSmpyY.js} +1 -1
- package/static/app/assets/{circle-play-7lEaqHdJ.js → circle-play-CMcKDVwu.js} +1 -1
- package/static/app/assets/{cpu-DAlCXlIy.js → cpu-B_MyTfYr.js} +1 -1
- package/static/app/assets/{download-RNhuuJwh.js → download-CTy0EByV.js} +1 -1
- package/static/app/assets/{folder-open-CLW4odzM.js → folder-open-DF6bLhTg.js} +1 -1
- package/static/app/assets/{hard-drive-NKEiDIAJ.js → hard-drive-CpWHG77C.js} +1 -1
- package/static/app/assets/{index-DMurvUuR.js → index-Dd6abJHX.js} +3 -3
- package/static/app/assets/index-DxmOfNRi.css +2 -0
- package/static/app/assets/{input-D2UhPC1X.js → input-bVgIRN3s.js} +1 -1
- package/static/app/assets/{link-2-6amKbP_P.js → link-2-bg6CG5Vk.js} +1 -1
- package/static/app/assets/{permissionCopy-Cu9TZtdR.js → permissionCopy-DFTf1HDZ.js} +1 -1
- package/static/app/assets/{primitives-gPsccucr.js → primitives-D7D-sQ3T.js} +1 -1
- package/static/app/assets/search-BgSVX6NG.js +1 -0
- package/static/app/assets/{share-2-Bau7KkPq.js → share-2-BLzo7U4L.js} +1 -1
- package/static/app/assets/{shield-alert-BufNYypi.js → shield-alert-BmngqnsJ.js} +1 -1
- package/static/app/assets/{textarea-BQnVWhYs.js → textarea-DOSlAQQ3.js} +1 -1
- package/static/app/assets/{useFocusTrap-B3_w60si.js → useFocusTrap-pZhgeee4.js} +1 -1
- package/static/app/assets/{useMutation-BHhCflT6.js → useMutation-BMDwNk4I.js} +1 -1
- package/static/app/assets/{useQuery-rBWfI-5t.js → useQuery-CSjKttRo.js} +1 -1
- package/static/app/assets/{utils-V_5-wxr5.js → utils-D3u_yv7B.js} +1 -1
- package/static/app/assets/{workspace-K1zjYUHj.js → workspace-H3bMjYXC.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- 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
|
+
]
|
|
@@ -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.5.
|
|
13
|
+
MARKETPLACE_VERSION = "11.5.1"
|
|
14
14
|
TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
|
|
15
15
|
|
|
16
16
|
|
|
@@ -536,6 +536,41 @@ MESSAGES: Dict[str, Dict[str, str]] = {
|
|
|
536
536
|
"ko": "한 번에 처리할 개수는 {min}에서 {max} 사이여야 합니다.",
|
|
537
537
|
"en": "Ask for between {min} and {max} items in one pass.",
|
|
538
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
|
+
},
|
|
539
574
|
# ── MCP / setup ─────────────────────────────────────────────────────
|
|
540
575
|
"mcp.connector_not_found": {
|
|
541
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.5.
|
|
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,
|
|
@@ -48,9 +49,12 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
|
|
|
48
49
|
from latticeai.services.chronicle import ChronicleService
|
|
49
50
|
from latticeai.services.command_center import CommandCenterService
|
|
50
51
|
from latticeai.services.evidence_actions import EvidenceActionService
|
|
51
|
-
from latticeai.services.tool_dispatch import
|
|
52
|
+
from latticeai.services.tool_dispatch import (
|
|
53
|
+
DEFAULT_TOOL_DISPATCH_SERVICE,
|
|
54
|
+
get_tool_permission,
|
|
55
|
+
)
|
|
52
56
|
from latticeai.services.voice_capture import VoiceCaptureService
|
|
53
|
-
from latticeai.tools import resolve_workspace_path
|
|
57
|
+
from latticeai.tools import execute_tool, resolve_workspace_path
|
|
54
58
|
|
|
55
59
|
# v2 Agentic Workspace Platform: cross-system wiring.
|
|
56
60
|
platform_automation_runtime = build_platform_automation_runtime(
|
|
@@ -210,6 +214,23 @@ def phase_platform_features(ctx: RuntimeContext) -> None:
|
|
|
210
214
|
)
|
|
211
215
|
)
|
|
212
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
|
+
|
|
213
234
|
# Evidence → action (v9.9.6): an answer's citations become ready-to-send,
|
|
214
235
|
# evidence-scoped follow-ups. Deterministic composition only — execution
|
|
215
236
|
# stays on the chat/file-generation path.
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ const root = process.cwd();
|
|
|
6
6
|
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
7
7
|
const version = pkg.version;
|
|
8
8
|
const releaseDir = `output/release/v${version}`;
|
|
9
|
-
const releaseTheme = "Rust
|
|
9
|
+
const releaseTheme = "Rust Full Loop";
|
|
10
10
|
const title = `${version} — ${releaseTheme}`;
|
|
11
11
|
const escapedVersion = version.replaceAll(".", "\\.");
|
|
12
12
|
|