ltcai 9.9.1 → 9.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/docs/CHANGELOG.md +36 -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/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +3 -0
- package/latticeai/api/chat_agent_http.py +2 -1
- package/latticeai/api/chat_intents.py +130 -7
- package/latticeai/app_factory.py +1 -0
- package/latticeai/core/agent.py +114 -2
- package/latticeai/core/file_generation.py +58 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +6 -1
- package/latticeai/services/tool_dispatch.py +36 -0
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +27 -27
- package/static/app/assets/{Act-CcSWTQsx.js → Act-Cx_gZNbE.js} +1 -1
- package/static/app/assets/{Brain-CSCDZEHd.js → Brain-BnGbytHa.js} +1 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +2 -0
- package/static/app/assets/{BrainSignals-CmFD8PWk.js → BrainSignals-BP8-Qh2g.js} +1 -1
- package/static/app/assets/{Capture-BiuerOio.js → Capture-CAKnLcb0.js} +1 -1
- package/static/app/assets/{CommandPalette-BrqpT7K2.js → CommandPalette-BoUTXBfm.js} +1 -1
- package/static/app/assets/{Library-BcaiPbum.js → Library-PcfVUco6.js} +1 -1
- package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-C3VbogVo.js} +1 -1
- package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-BLau7c7N.js} +1 -1
- package/static/app/assets/{ReviewCard-D56TMypk.js → ReviewCard-CX5RqRqx.js} +1 -1
- package/static/app/assets/{System-D6uJacqu.js → System-DRHc_6Su.js} +1 -1
- package/static/app/assets/{bot-DgC7xgUl.js → bot-DZkDdh-A.js} +1 -1
- package/static/app/assets/{cpu-DDGb4XHl.js → cpu-DyvsDBbj.js} +1 -1
- package/static/app/assets/{download-C-Ei3GFa.js → download-C7yxYHvz.js} +1 -1
- package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-CffoIr9t.js} +1 -1
- package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-C1oojrHb.js} +1 -1
- package/static/app/assets/{index-DrZSkwrE.js → index-BS_3aN1n.js} +3 -3
- package/static/app/assets/index-DU78Kogz.css +2 -0
- package/static/app/assets/{input-DPIkTs9n.js → input-B_U7zvbR.js} +1 -1
- package/static/app/assets/{navigation-B1mkmyUe.js → navigation-xG-gyjbe.js} +1 -1
- package/static/app/assets/{network-0XDI1Uf_.js → network-BF13HJJF.js} +1 -1
- package/static/app/assets/{primitives-sIQYKyf5.js → primitives-DsuKh3M8.js} +1 -1
- package/static/app/assets/{sunrise-DXMjueec.js → sunrise-CIP2ELJ1.js} +1 -1
- package/static/app/assets/{textarea-DvnwpnqS.js → textarea-DsDL4ET8.js} +1 -1
- package/static/app/assets/utils-Dh-Dwtkb.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
- package/static/app/assets/index-CFRhNBuH.css +0 -2
- package/static/app/assets/utils-BrfFHAJl.js +0 -7
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[](https://github.com/TaeSooPark-PTS/LatticeAI/actions/workflows/ci.yml)
|
|
12
12
|
[](LICENSE)
|
|
13
13
|
|
|
14
|
-

|
|
15
15
|
|
|
16
16
|
Chat, files, folders, notes, and web pages all flow into one durable knowledge
|
|
17
17
|
graph on your computer. Any model — local MLX or cloud — can speak with that
|
|
@@ -24,9 +24,9 @@ memory. Nothing leaves your machine without explicit consent.
|
|
|
24
24
|
|
|
25
25
|
| | |
|
|
26
26
|
| --- | --- |
|
|
27
|
-
| **Chat with a Brain that remembers** — every conversation grows durable, source-linked memory  | **See how knowledge connects** — a real relationship graph, not a file list  |
|
|
28
|
+
| **Capture anything** — files, whole folders, notes, screenshots, web pages  | **Automate with review** — agent changes become proposals you approve first  |
|
|
29
|
+
| **Pick a model in one click** — recommended local models for your hardware  | **Stay in control** — audit, roles, retention in a separate admin surface  |
|
|
30
30
|
|
|
31
31
|
## Why Lattice AI
|
|
32
32
|
|
|
@@ -57,14 +57,14 @@ First-run flow — wake the Brain, pick the owner, load a recommended model:
|
|
|
57
57
|
|
|
58
58
|
| | | |
|
|
59
59
|
| --- | --- | --- |
|
|
60
|
-
|  |  |  |
|
|
61
61
|
|
|
62
62
|
Screenshot index and capture notes:
|
|
63
|
-
[output/release/v9.9.
|
|
63
|
+
[output/release/v9.9.2/SCREENSHOT_INDEX.md](output/release/v9.9.2/SCREENSHOT_INDEX.md)
|
|
64
64
|
|
|
65
65
|
## Current Release
|
|
66
66
|
|
|
67
|
-
The current release is **9.9.
|
|
67
|
+
The current release is **9.9.2 — Artifact Trust**:
|
|
68
68
|
|
|
69
69
|
- **A clean core.** The historical repo-root compatibility shims are gone
|
|
70
70
|
(only `server.py` remains for `uvicorn server:app`); packaging ships exactly
|
|
@@ -93,13 +93,13 @@ The current release is **9.9.1 — Clean Foundations**:
|
|
|
93
93
|
|
|
94
94
|
Release notes: [RELEASE.md](RELEASE.md) · Full history: [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
|
95
95
|
|
|
96
|
-
Expected artifacts for 9.9.
|
|
96
|
+
Expected artifacts for 9.9.2 release must use exact filenames:
|
|
97
97
|
|
|
98
|
-
- `dist/ltcai-9.9.
|
|
99
|
-
- `dist/ltcai-9.9.
|
|
100
|
-
- `ltcai-9.9.
|
|
101
|
-
- `dist/ltcai-9.9.
|
|
102
|
-
- `src-tauri/target/release/bundle/dmg/Lattice AI_9.9.
|
|
98
|
+
- `dist/ltcai-9.9.2-py3-none-any.whl`
|
|
99
|
+
- `dist/ltcai-9.9.2.tar.gz`
|
|
100
|
+
- `ltcai-9.9.2.tgz`
|
|
101
|
+
- `dist/ltcai-9.9.2.vsix`
|
|
102
|
+
- `src-tauri/target/release/bundle/dmg/Lattice AI_9.9.2_aarch64.dmg`
|
|
103
103
|
|
|
104
104
|
Do not use wildcard artifact uploads. Package registry publishing remains owner-run.
|
|
105
105
|
|
|
@@ -119,7 +119,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for details and
|
|
|
119
119
|
|
|
120
120
|
- External package registries are owner-published and can lag behind GitHub.
|
|
121
121
|
- PostgreSQL/pgvector is optional scale/migration tooling. SQLite remains the
|
|
122
|
-
live local Brain store in 9.9.
|
|
122
|
+
live local Brain store in 9.9.2.
|
|
123
123
|
- Docker, model downloads, cloud model calls, Telegram, Brain Network, and
|
|
124
124
|
update checks require explicit user action.
|
|
125
125
|
- Conversation does not fabricate answers when no model is loaded. Agent and
|
|
@@ -131,6 +131,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for details and
|
|
|
131
131
|
|
|
132
132
|
| Version | Theme |
|
|
133
133
|
| --- | --- |
|
|
134
|
+
| 9.9.2 | Artifact Trust |
|
|
134
135
|
| 9.9.1 | Clean Foundations |
|
|
135
136
|
| 9.9.0 | Fail-Closed Trust |
|
|
136
137
|
| 9.8.0 | Honest Knowledge Pipeline |
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,42 @@ The top entry is either the current unreleased main-branch work or the current
|
|
|
4
4
|
release line. Older entries are historical and may describe behavior as it
|
|
5
5
|
existed at that release.
|
|
6
6
|
|
|
7
|
+
## [9.9.2] - 2026-07-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- ArtifactWritePipeline: `sanitize_write_content` in
|
|
11
|
+
`latticeai/core/file_generation.py` — a conservative validate-first →
|
|
12
|
+
extract → repair gate. The agent executor applies it to every `write_file`
|
|
13
|
+
`args.content` before dispatch, records a `content_sanitize` verdict on the
|
|
14
|
+
transcript, and tags `artifact_sanitize`/`artifact_repair` in the loop trace.
|
|
15
|
+
- Artifact-first chat contract: direct file-creation responses and agent runs
|
|
16
|
+
carry an `artifacts[]` array (`kind/path/filename/bytes/previewable/valid/
|
|
17
|
+
repaired`) via `collect_artifacts(transcript)`.
|
|
18
|
+
- Direct-path overwrite protection: an existing target is auto-suffixed
|
|
19
|
+
(`generated_page_2.html`) and announced, instead of silently overwritten.
|
|
20
|
+
- Generated files are optionally indexed into the Brain through the unified
|
|
21
|
+
`IngestionPipeline` (`workspace://` provenance, `origin: generated_file`);
|
|
22
|
+
disable with `LATTICEAI_INGEST_GENERATED=0`. The response reports an honest
|
|
23
|
+
`brain_ingest` status; ingest failures never fail the file creation.
|
|
24
|
+
- Plan schema enforcement (`normalize_plan`): non-empty goal, junk-step
|
|
25
|
+
filtering, `estimated_steps` clamping, and a deterministic single
|
|
26
|
+
`write_file` step for file-intent requests whose plan came back empty.
|
|
27
|
+
- Memory quality filter (`filter_learnings`): trivial and duplicate agent
|
|
28
|
+
learnings are dropped before entering the Brain.
|
|
29
|
+
- FG harness `tests/unit/test_artifact_write_scenarios.py` (FG-01..FG-08):
|
|
30
|
+
intent gating, filename inference, dirty-output extraction, truncated-HTML
|
|
31
|
+
repair, agent-path sanitization, how-to non-routing, scaffold validity.
|
|
32
|
+
- Frontend honesty surfaces: "Auto-repaired" badge on file cards
|
|
33
|
+
(`generation.repaired`), and a distinct warning strip for `NEEDS_REVIEW` /
|
|
34
|
+
`FAILED` terminal agent states (`role="alert"`, ko/en, dark/light).
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- HTML file validation now rejects documents wrapped in prose or Markdown
|
|
38
|
+
fences, so extraction gets a chance to slice out the real document instead
|
|
39
|
+
of the wrapper being saved as "valid".
|
|
40
|
+
- `product_readiness` `action-aware-chat` gate additionally proves the
|
|
41
|
+
ArtifactWritePipeline evidence (module, agent seam, FG harness test).
|
|
42
|
+
|
|
7
43
|
## [9.9.1] - 2026-07-21
|
|
8
44
|
|
|
9
45
|
### Removed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Community And Plugins
|
|
2
2
|
|
|
3
|
-
Current release: **9.9.
|
|
3
|
+
Current release: **9.9.2 — Artifact Trust**.
|
|
4
4
|
|
|
5
5
|
LatticeAI defines the path from a strong local-first framework (8.4.0
|
|
6
6
|
action-aware baseline, 8.5.0 registry+DI hardening, 8.6.0 capture/navigation
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> **Status: canonical** — current contributor guidance, kept in sync with the
|
|
4
4
|
> current release.
|
|
5
5
|
|
|
6
|
-
Current release: **9.9.
|
|
6
|
+
Current release: **9.9.2 — Artifact Trust**.
|
|
7
7
|
|
|
8
8
|
This document is for contributors working on the local-first Digital Brain
|
|
9
9
|
codebase. Product positioning and quick start stay in `README.md`; release
|
package/docs/ONBOARDING.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Lattice AI Onboarding
|
|
2
2
|
|
|
3
|
-
Current release: **9.9.
|
|
3
|
+
Current release: **9.9.2 — Artifact Trust**.
|
|
4
4
|
|
|
5
5
|
The first-run goal is a five-minute path from "I opened the app" to "my Brain
|
|
6
6
|
has a source, a question, and proof." This page is the product contract behind
|
package/docs/OPERATIONS.md
CHANGED
package/docs/TRUST_MODEL.md
CHANGED
package/docs/WHY_LATTICE.md
CHANGED
package/docs/kg-schema.md
CHANGED
|
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
|
|
|
48
48
|
from ..utils import now_iso as _now
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
MULTI_AGENT_VERSION = "9.9.
|
|
51
|
+
MULTI_AGENT_VERSION = "9.9.2"
|
|
52
52
|
|
|
53
53
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
54
54
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/chat.py
CHANGED
|
@@ -208,6 +208,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
208
208
|
execute_tool=execute_tool,
|
|
209
209
|
agent_controller=agent_controller,
|
|
210
210
|
agent_root=AGENT_ROOT,
|
|
211
|
+
ingestion_pipeline=context.ingestion_pipeline,
|
|
211
212
|
)
|
|
212
213
|
document_coordinator = DocumentGenerationCoordinator(
|
|
213
214
|
model_router=model_router,
|
|
@@ -276,6 +277,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
276
277
|
direct_response = await intent_controller.direct_file_action(
|
|
277
278
|
req,
|
|
278
279
|
model_id=selected_model_id,
|
|
280
|
+
effective_email=effective_email,
|
|
281
|
+
workspace_id=workspace_id,
|
|
279
282
|
)
|
|
280
283
|
if direct_response is not None:
|
|
281
284
|
return direct_response
|
|
@@ -18,7 +18,7 @@ from lattice_brain.runtime.hooks import dispatch_tool
|
|
|
18
18
|
from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
|
|
19
19
|
from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
|
|
20
20
|
from latticeai.core.agent import AgentRunContext, AgentState
|
|
21
|
-
from latticeai.services.tool_dispatch import collect_created_files
|
|
21
|
+
from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class AgentHTTPController:
|
|
@@ -307,6 +307,7 @@ class AgentHTTPController:
|
|
|
307
307
|
"state_history": ctx.state_history,
|
|
308
308
|
"final_state": ctx.state.value,
|
|
309
309
|
"created_files": collect_created_files(ctx.transcript),
|
|
310
|
+
"artifacts": collect_artifacts(ctx.transcript),
|
|
310
311
|
"loop": ctx.trace.summary(),
|
|
311
312
|
}
|
|
312
313
|
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Dict, Optional
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
8
10
|
|
|
9
11
|
from fastapi import HTTPException, Request
|
|
10
12
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
@@ -17,7 +19,43 @@ from latticeai.api.chat_helpers import (
|
|
|
17
19
|
)
|
|
18
20
|
from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
|
|
19
21
|
from latticeai.core.agent import AgentState
|
|
20
|
-
from latticeai.core.file_generation import
|
|
22
|
+
from latticeai.core.file_generation import (
|
|
23
|
+
PREVIEWABLE_EXTENSIONS,
|
|
24
|
+
generate_file_content,
|
|
25
|
+
infer_file_target,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def next_available_path(root: Path, target: str) -> str:
|
|
30
|
+
"""Return ``target`` unchanged, or a ``name_2.ext``-style variant when the
|
|
31
|
+
file already exists in the workspace.
|
|
32
|
+
|
|
33
|
+
The direct chat write path must never silently overwrite an existing file:
|
|
34
|
+
overwrites belong to the reviewable-proposal flow. Deterministic suffixing
|
|
35
|
+
keeps "make me an html page" repeatable without a 409 dead end.
|
|
36
|
+
"""
|
|
37
|
+
candidate = (root / target)
|
|
38
|
+
if not candidate.exists():
|
|
39
|
+
return target
|
|
40
|
+
stem, suffix = candidate.stem, candidate.suffix
|
|
41
|
+
# An earlier auto-suffix run may have produced name_2 already; restart the
|
|
42
|
+
# numbering from the base name so we fill the first free slot.
|
|
43
|
+
base = re.sub(r"_\d+$", "", stem) or stem
|
|
44
|
+
for index in range(2, 100):
|
|
45
|
+
variant = candidate.with_name(f"{base}_{index}{suffix}")
|
|
46
|
+
if not variant.exists():
|
|
47
|
+
return str(Path(target).with_name(variant.name))
|
|
48
|
+
raise HTTPException(
|
|
49
|
+
status_code=409,
|
|
50
|
+
detail=f"'{target}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ingest_generated_enabled() -> bool:
|
|
55
|
+
"""Feature toggle for auto-indexing generated files into the Brain."""
|
|
56
|
+
return os.environ.get("LATTICEAI_INGEST_GENERATED", "1").strip().lower() not in {
|
|
57
|
+
"0", "false", "off", "no",
|
|
58
|
+
}
|
|
21
59
|
|
|
22
60
|
|
|
23
61
|
class ChatIntentController:
|
|
@@ -41,6 +79,7 @@ class ChatIntentController:
|
|
|
41
79
|
execute_tool: Any,
|
|
42
80
|
agent_controller: Any,
|
|
43
81
|
agent_root: Path,
|
|
82
|
+
ingestion_pipeline: Any = None,
|
|
44
83
|
) -> None:
|
|
45
84
|
self.router = model_router
|
|
46
85
|
self.config = config
|
|
@@ -59,6 +98,7 @@ class ChatIntentController:
|
|
|
59
98
|
self.execute_tool = execute_tool
|
|
60
99
|
self.agent_controller = agent_controller
|
|
61
100
|
self.agent_root = Path(agent_root)
|
|
101
|
+
self.ingestion_pipeline = ingestion_pipeline
|
|
62
102
|
|
|
63
103
|
def no_model_response(self) -> JSONResponse:
|
|
64
104
|
detail = "No model loaded. Call /models/load first."
|
|
@@ -184,13 +224,25 @@ class ChatIntentController:
|
|
|
184
224
|
)
|
|
185
225
|
return single_answer_response(req, answer, model="client_url")
|
|
186
226
|
|
|
187
|
-
async def direct_file_action(
|
|
227
|
+
async def direct_file_action(
|
|
228
|
+
self,
|
|
229
|
+
req: Any,
|
|
230
|
+
*,
|
|
231
|
+
model_id: Optional[str],
|
|
232
|
+
effective_email: Optional[str] = None,
|
|
233
|
+
workspace_id: Optional[str] = None,
|
|
234
|
+
):
|
|
188
235
|
# An explicit path ("report.txt") wins; otherwise infer one from an
|
|
189
236
|
# explicit type keyword ("html 파일 만들어줘") so weak models never
|
|
190
237
|
# have to drive the JSON tool loop just to create a file.
|
|
191
238
|
target_path = file_action_target(req.message) or infer_file_target(req.message)
|
|
192
239
|
if not target_path:
|
|
193
240
|
return None
|
|
241
|
+
# Never silently overwrite: an existing target gets a _2/_3 suffix so
|
|
242
|
+
# regeneration is safe and repeatable without the proposal flow.
|
|
243
|
+
deduped_path = next_available_path(self.agent_root, target_path)
|
|
244
|
+
renamed = deduped_path != target_path
|
|
245
|
+
target_path = deduped_path
|
|
194
246
|
content = inline_file_action_content(req.message)
|
|
195
247
|
generation_meta: Optional[Dict[str, Any]] = None
|
|
196
248
|
if content is None and not model_id:
|
|
@@ -227,19 +279,43 @@ class ChatIntentController:
|
|
|
227
279
|
)
|
|
228
280
|
except self.tool_error as exc:
|
|
229
281
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
230
|
-
|
|
231
|
-
|
|
282
|
+
final_path = result.get("path") or target_path
|
|
283
|
+
answer = f"{final_path} 파일을 만들었습니다."
|
|
284
|
+
if renamed:
|
|
285
|
+
answer += " (같은 이름의 파일이 있어 새 이름으로 저장했습니다.)"
|
|
286
|
+
repaired = bool(generation_meta and generation_meta.get("repaired"))
|
|
287
|
+
if repaired:
|
|
232
288
|
answer += (
|
|
233
289
|
" (모델 출력이 불완전해 자동 보정을 거쳐 유효한 파일로 저장했습니다.)"
|
|
234
290
|
)
|
|
235
291
|
created_files = [
|
|
236
292
|
{
|
|
237
|
-
"path":
|
|
238
|
-
"filename": Path(
|
|
293
|
+
"path": final_path,
|
|
294
|
+
"filename": Path(final_path).name,
|
|
239
295
|
"bytes": result.get("bytes", 0),
|
|
240
296
|
"action": "write_file",
|
|
241
297
|
}
|
|
242
298
|
]
|
|
299
|
+
# Artifact-first contract: every file the request produced, with the
|
|
300
|
+
# honesty flags the UI needs (valid vs repaired scaffold, previewable).
|
|
301
|
+
artifacts: List[Dict[str, Any]] = [
|
|
302
|
+
{
|
|
303
|
+
"kind": "file",
|
|
304
|
+
"path": final_path,
|
|
305
|
+
"filename": Path(final_path).name,
|
|
306
|
+
"bytes": result.get("bytes", 0),
|
|
307
|
+
"previewable": Path(final_path).suffix.lower() in PREVIEWABLE_EXTENSIONS,
|
|
308
|
+
"valid": True,
|
|
309
|
+
"repaired": repaired,
|
|
310
|
+
}
|
|
311
|
+
]
|
|
312
|
+
brain_ingest = self._ingest_generated_file(
|
|
313
|
+
final_path,
|
|
314
|
+
content,
|
|
315
|
+
effective_email=effective_email,
|
|
316
|
+
workspace_id=workspace_id,
|
|
317
|
+
conversation_id=getattr(req, "conversation_id", None),
|
|
318
|
+
)
|
|
243
319
|
self.notify("user", req.message, req.source)
|
|
244
320
|
self.notify("assistant", answer, req.source)
|
|
245
321
|
payload = {
|
|
@@ -257,11 +333,14 @@ class ChatIntentController:
|
|
|
257
333
|
"state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
|
|
258
334
|
"final_state": AgentState.DONE.value,
|
|
259
335
|
"created_files": created_files,
|
|
336
|
+
"artifacts": artifacts,
|
|
260
337
|
"routed_to_agent": True,
|
|
261
338
|
"action_route": "direct_write_file",
|
|
262
339
|
}
|
|
263
340
|
if generation_meta is not None:
|
|
264
341
|
payload["generation"] = generation_meta
|
|
342
|
+
if brain_ingest is not None:
|
|
343
|
+
payload["brain_ingest"] = brain_ingest
|
|
265
344
|
if req.stream:
|
|
266
345
|
return StreamingResponse(
|
|
267
346
|
agent_payload_stream(
|
|
@@ -275,6 +354,50 @@ class ChatIntentController:
|
|
|
275
354
|
)
|
|
276
355
|
return JSONResponse(content=payload)
|
|
277
356
|
|
|
357
|
+
def _ingest_generated_file(
|
|
358
|
+
self,
|
|
359
|
+
rel_path: str,
|
|
360
|
+
content: Optional[str],
|
|
361
|
+
*,
|
|
362
|
+
effective_email: Optional[str],
|
|
363
|
+
workspace_id: Optional[str],
|
|
364
|
+
conversation_id: Optional[str],
|
|
365
|
+
) -> Optional[Dict[str, Any]]:
|
|
366
|
+
"""Optionally index a just-generated file into the Brain.
|
|
367
|
+
|
|
368
|
+
The knowledge promise is "what Lattice makes, Lattice remembers":
|
|
369
|
+
without this, generated files are invisible to recall until the user
|
|
370
|
+
re-uploads them. Best-effort and additive — ingestion problems never
|
|
371
|
+
fail the file creation, and LATTICEAI_INGEST_GENERATED=0 disables it.
|
|
372
|
+
"""
|
|
373
|
+
if self.ingestion_pipeline is None or not ingest_generated_enabled():
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
from lattice_brain.ingestion import IngestionItem
|
|
377
|
+
|
|
378
|
+
item = IngestionItem(
|
|
379
|
+
source_type="file",
|
|
380
|
+
title=Path(rel_path).name,
|
|
381
|
+
text=content,
|
|
382
|
+
path=str(self.agent_root / rel_path),
|
|
383
|
+
source_uri=f"workspace://{rel_path}",
|
|
384
|
+
owner=effective_email,
|
|
385
|
+
workspace_id=workspace_id,
|
|
386
|
+
conversation_id=conversation_id,
|
|
387
|
+
metadata={"origin": "generated_file", "route": "direct_write_file"},
|
|
388
|
+
)
|
|
389
|
+
result = self.ingestion_pipeline.ingest(item, user_email=effective_email)
|
|
390
|
+
payload = result.as_dict() if hasattr(result, "as_dict") else dict(result or {})
|
|
391
|
+
return {
|
|
392
|
+
"status": payload.get("status"),
|
|
393
|
+
"node_id": payload.get("node_id"),
|
|
394
|
+
"chunk_count": payload.get("chunk_count", 0),
|
|
395
|
+
"duplicate": payload.get("duplicate", False),
|
|
396
|
+
}
|
|
397
|
+
except Exception as exc: # noqa: BLE001 — indexing must never break creation
|
|
398
|
+
logging.warning("generated-file ingest failed: %s", exc)
|
|
399
|
+
return {"status": "failed", "detail": str(exc)[:200]}
|
|
400
|
+
|
|
278
401
|
async def route_file_to_agent(
|
|
279
402
|
self,
|
|
280
403
|
req: Any,
|
package/latticeai/app_factory.py
CHANGED
|
@@ -911,6 +911,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
911
911
|
chat_service=CHAT_SERVICE,
|
|
912
912
|
context_assembler=CONTEXT_ASSEMBLER,
|
|
913
913
|
brain_memory=BRAIN_MEMORY,
|
|
914
|
+
ingestion_pipeline=INGESTION_PIPELINE if ENABLE_GRAPH else None,
|
|
914
915
|
chat_agent_runtime=CHAT_AGENT_RUNTIME,
|
|
915
916
|
gardener=gardener,
|
|
916
917
|
hooks=HOOKS_REGISTRY,
|
package/latticeai/core/agent.py
CHANGED
|
@@ -32,6 +32,7 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tu
|
|
|
32
32
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
33
33
|
from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
|
|
34
34
|
from latticeai.core.agent_trace import LoopTrace
|
|
35
|
+
from latticeai.core.file_generation import infer_file_target, sanitize_write_content
|
|
35
36
|
from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
|
|
36
37
|
from latticeai.tools import ToolError
|
|
37
38
|
|
|
@@ -141,6 +142,91 @@ def extract_action(raw: str) -> Dict:
|
|
|
141
142
|
return action
|
|
142
143
|
|
|
143
144
|
|
|
145
|
+
def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[str]]:
|
|
146
|
+
"""Enforce the minimal plan schema so execution never starts adrift.
|
|
147
|
+
|
|
148
|
+
A weak planner that returns junk steps / a missing goal previously flowed
|
|
149
|
+
straight into the executor, which then had to reconstruct intent from the
|
|
150
|
+
raw request. Normalization keeps the loop honest: ``goal`` is always a
|
|
151
|
+
non-empty string, ``steps`` only contains dicts with an ``action``, and an
|
|
152
|
+
empty plan for an obvious file-creation request gets a deterministic
|
|
153
|
+
single ``write_file`` step instead of leaving the executor to improvise.
|
|
154
|
+
|
|
155
|
+
Returns ``(plan, fixes)`` where ``fixes`` names every applied repair —
|
|
156
|
+
the loop trace records them so plan quality is observable per model.
|
|
157
|
+
"""
|
|
158
|
+
fixes: List[str] = []
|
|
159
|
+
if not isinstance(plan, dict):
|
|
160
|
+
plan = {}
|
|
161
|
+
fixes.append("plan_not_object")
|
|
162
|
+
plan = dict(plan)
|
|
163
|
+
|
|
164
|
+
goal = str(plan.get("goal") or "").strip()
|
|
165
|
+
if not goal:
|
|
166
|
+
plan["goal"] = user_message
|
|
167
|
+
fixes.append("goal_defaulted")
|
|
168
|
+
|
|
169
|
+
raw_steps = plan.get("steps")
|
|
170
|
+
steps = [
|
|
171
|
+
s for s in (raw_steps if isinstance(raw_steps, list) else [])
|
|
172
|
+
if isinstance(s, dict) and s.get("action")
|
|
173
|
+
]
|
|
174
|
+
if raw_steps and steps != raw_steps:
|
|
175
|
+
fixes.append("steps_filtered")
|
|
176
|
+
if not steps:
|
|
177
|
+
inferred = infer_file_target(user_message)
|
|
178
|
+
if inferred:
|
|
179
|
+
steps = [{
|
|
180
|
+
"action": "write_file",
|
|
181
|
+
"args": {"path": inferred},
|
|
182
|
+
"description": f"Create {inferred} for: {user_message[:120]}",
|
|
183
|
+
}]
|
|
184
|
+
fixes.append("heuristic_file_step")
|
|
185
|
+
plan["steps"] = steps
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
estimated = int(plan.get("estimated_steps") or 0)
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
estimated = 0
|
|
191
|
+
fixes.append("estimated_steps_invalid")
|
|
192
|
+
plan["estimated_steps"] = max(1, estimated, len(steps))
|
|
193
|
+
plan["requires_approval"] = bool(plan.get("requires_approval", False))
|
|
194
|
+
if not isinstance(plan.get("rollback_strategy"), str):
|
|
195
|
+
plan["rollback_strategy"] = "none"
|
|
196
|
+
return plan, fixes
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_TRIVIAL_LEARNING_RE = re.compile(
|
|
200
|
+
r"^(파일(을|이)?\s*(만들|생성|작성|저장)|작업(을|이)?\s*(완료|성공)|성공적으로"
|
|
201
|
+
r"|task\s+(was\s+)?complet|file\s+(was\s+)?(creat|written|saved)"
|
|
202
|
+
r"|(successfully\s+)?(created|completed|finished|done)\b)",
|
|
203
|
+
re.IGNORECASE,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def filter_learnings(learnings: List[Any]) -> List[str]:
|
|
208
|
+
"""Drop trivial/duplicate learnings before they enter the brain.
|
|
209
|
+
|
|
210
|
+
"파일을 만들었다"-class statements restate what the transcript already
|
|
211
|
+
records and pollute recall. A learning survives when it is long enough to
|
|
212
|
+
carry information and is not a bare completion announcement.
|
|
213
|
+
"""
|
|
214
|
+
kept: List[str] = []
|
|
215
|
+
seen: set = set()
|
|
216
|
+
for raw in learnings or []:
|
|
217
|
+
text = str(raw or "").strip()
|
|
218
|
+
if len(text) < 12:
|
|
219
|
+
continue
|
|
220
|
+
if _TRIVIAL_LEARNING_RE.match(text) and len(text) < 48:
|
|
221
|
+
continue
|
|
222
|
+
key = text.lower()
|
|
223
|
+
if key in seen:
|
|
224
|
+
continue
|
|
225
|
+
seen.add(key)
|
|
226
|
+
kept.append(text)
|
|
227
|
+
return kept
|
|
228
|
+
|
|
229
|
+
|
|
144
230
|
@dataclass
|
|
145
231
|
class AgentDeps:
|
|
146
232
|
"""The ports a :class:`SingleAgentRuntime` needs from the outside world.
|
|
@@ -258,6 +344,9 @@ class SingleAgentRuntime:
|
|
|
258
344
|
"goal": req.message, "steps": [],
|
|
259
345
|
"requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
|
|
260
346
|
}
|
|
347
|
+
plan, plan_fixes = normalize_plan(plan, req.message)
|
|
348
|
+
if plan_fixes:
|
|
349
|
+
ctx.trace.repair("plan", repairs=plan_fixes)
|
|
261
350
|
ctx.plan = plan
|
|
262
351
|
ctx.transcript.append({
|
|
263
352
|
"state": AgentState.PLANNING.value,
|
|
@@ -266,6 +355,7 @@ class SingleAgentRuntime:
|
|
|
266
355
|
"requires_approval": plan.get("requires_approval", False),
|
|
267
356
|
"rollback_strategy": plan.get("rollback_strategy", "none"),
|
|
268
357
|
"estimated_steps": plan.get("estimated_steps", 1),
|
|
358
|
+
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
269
359
|
})
|
|
270
360
|
ctx.state = AgentState.WAITING_APPROVAL
|
|
271
361
|
|
|
@@ -557,6 +647,26 @@ class SingleAgentRuntime:
|
|
|
557
647
|
) -> None:
|
|
558
648
|
"""Role check + shared tool lifecycle, recorded on the transcript either way."""
|
|
559
649
|
d = self.deps
|
|
650
|
+
sanitize_meta: Optional[Dict[str, Any]] = None
|
|
651
|
+
if name == "write_file" and isinstance(args.get("content"), str):
|
|
652
|
+
# ArtifactWritePipeline: the executor's args.content is untrusted
|
|
653
|
+
# model output. The same extract→validate→repair guarantee as the
|
|
654
|
+
# direct chat path applies here, so a weak model driving the JSON
|
|
655
|
+
# loop can never persist fenced/chatty/truncated payloads.
|
|
656
|
+
cleaned, meta = sanitize_write_content(
|
|
657
|
+
str(args.get("path") or ""), args["content"],
|
|
658
|
+
user_request=str(ctx.plan.get("goal") or thoughts or name),
|
|
659
|
+
)
|
|
660
|
+
if meta.get("sanitized"):
|
|
661
|
+
args = dict(args)
|
|
662
|
+
args["content"] = cleaned
|
|
663
|
+
sanitize_meta = meta
|
|
664
|
+
ctx.trace.repair(
|
|
665
|
+
"execute",
|
|
666
|
+
repairs=[
|
|
667
|
+
"artifact_repair" if meta.get("repaired") else "artifact_sanitize"
|
|
668
|
+
],
|
|
669
|
+
)
|
|
560
670
|
try:
|
|
561
671
|
d.check_role(name, current_user)
|
|
562
672
|
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
@@ -570,6 +680,7 @@ class SingleAgentRuntime:
|
|
|
570
680
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
571
681
|
"thoughts": thoughts, "args": args,
|
|
572
682
|
"risk": risk, "governance": dict(policy), "result": result,
|
|
683
|
+
**({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
|
|
573
684
|
})
|
|
574
685
|
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
575
686
|
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
@@ -791,8 +902,9 @@ class SingleAgentRuntime:
|
|
|
791
902
|
context=context, max_tokens=256, temperature=0.1,
|
|
792
903
|
)
|
|
793
904
|
mem = extract_action(str(raw))
|
|
794
|
-
|
|
795
|
-
|
|
905
|
+
kept_learnings = filter_learnings(mem.get("learnings") or [])
|
|
906
|
+
if mem.get("save_to_knowledge") and kept_learnings:
|
|
907
|
+
learnings = "\n".join(kept_learnings)
|
|
796
908
|
if d.brain_memory is not None:
|
|
797
909
|
# This runtime is LLM-driven — its learnings are real
|
|
798
910
|
# experiences and enter the brain with provenance.
|