mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
Compare commits
68 Commits
v1.2.0-bet
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| d17c18665c | |||
| 201a02839e | |||
| f0be8cbaa5 | |||
| 9f80289a89 | |||
| ae9b06976c | |||
| 6e6035141a | |||
| aba1770701 | |||
| cba33d2a6f | |||
| 2ebbdadcd6 | |||
| 48cfa8fb6c | |||
| 93aa942e1e | |||
| c5b1242980 | |||
| f0562915db | |||
| 818ce2698f | |||
| 36e7556a02 | |||
| 069552e921 | |||
| f7c3a4e81d | |||
| a2dcd96fee | |||
| 3c961b7bd7 | |||
| f2ec46480e | |||
| 3956610134 | |||
| fc41e160db | |||
| 158395bfdf | |||
| f3232000f4 | |||
| cb32032f76 | |||
| 86a702b3b0 | |||
| d4764e3682 | |||
| 8372f5bd8f | |||
| 98d67559d1 | |||
| 6bd3313a22 | |||
| f11de37bf2 | |||
| 4e4e0ad656 | |||
| a971fc8877 | |||
| 7565248f61 | |||
| c69a1f11af | |||
| 716ee46471 | |||
| 7082a50365 | |||
| 9a588780b9 | |||
| 06edff1af2 | |||
| 163c5f70b3 | |||
| ca5e62f775 | |||
| 07e08352b9 | |||
| 8f40b91dea | |||
| d1662e77cf | |||
| dc4982464d | |||
| fe883d5362 | |||
| 5fbd6514c2 | |||
| 5aaae93367 | |||
| 46e014a8ca | |||
| ea21803acb | |||
| 10367b384e | |||
| ce8bc27db8 | |||
| 7318a10828 | |||
| b15d0bd114 | |||
| 34628720f2 | |||
| 85223fc1c9 | |||
| dc80855540 | |||
| 0b1c201b5c | |||
| 5e10f012cb | |||
| 727768e083 | |||
| 0247206dfe | |||
| fbd3adabea | |||
| f778c613f1 | |||
| 7a418346a4 | |||
| a070cb563d | |||
| 685ed7e4a0 | |||
| dd99105d66 | |||
| 1fb5700662 |
@ -190,7 +190,7 @@ internal/
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
@ -209,7 +209,7 @@ internal/
|
||||
是块的**迁移**,不是复制、也不靠引用保活。
|
||||
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
|
||||
RPC 只传偏移描述符;**RPC 协议升到 2**,fd3 布局改变,**不支持滚动升级**——
|
||||
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `plugindev` 重编。
|
||||
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
|
||||
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`(**默认仍记入记忆、默认不裁剪**);
|
||||
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**。
|
||||
- **发行包默认启用** ONNX 向量空间,并把模型(754MB)与 ONNX Runtime(24MB)随
|
||||
@ -226,7 +226,7 @@ internal/
|
||||
|
||||
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储(CAS + SQLite 元数据 + 磁盘 blob,`Get` always 重校 digest),贯通 L0(上下文事件)/L2(文档)/L3(图谱句子)三层,引用计数式 GC(有引用者绝不删)。视觉模型生成的描述文本是持久语义记忆,blob 只是可被容量 GC 淘汰的缓存。
|
||||
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`,**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`,**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE` 让 `dlclose` 成 no-op)、崩溃隔离缺失(插件 panic 带崩 homed)、stage lost update(副本模型丢失 35.8~36.8%)、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功(模型收到「已发送」而消息未送达)、Windows 能力断层(只见 3 个 stage 字段且无法写回)。三面通信:stdio JSON-RPC(控制)+ 共享内存段(数据)+ 事件环(通知);权限梯度显式化为三道闸。RPC 往返 p50 24.1µs,崩溃到恢复 <1s。
|
||||
|
||||
**v0.9.0** — C ABI v2:外部插件 Stage 回调支持写回(`invoke_stage` 增加 result 输出,插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核),ABI 版本随内核 minor 对齐(v0.9.x → ABIVersion=2,`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题。配套 SDK 提供增强版 sanitizer 示例(坏 UTF-8/U+FFFD/ANSI 转义全链路清洗)。**该 ABI 已随 v1.0.0 退场。**
|
||||
|
||||
|
||||
@ -179,7 +179,7 @@ internal/
|
||||
├── config/ SQLite config center
|
||||
├── events/ Event bus
|
||||
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
|
||||
```
|
||||
|
||||
## Project Status
|
||||
@ -240,7 +240,7 @@ by `-race`; in production this showed up as sporadic nil-dereference crashes dur
|
||||
items are never deleted). The description text produced by the vision model is the durable
|
||||
semantic memory; the blob is only a cache that capacity GC may evict.
|
||||
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** — though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
|
||||
|
||||
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
|
||||
|
||||
|
||||
@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
RelevanceContext — In-memory events[] + JSON persistence
|
||||
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
|
||||
agent→Response, user→Input, cold_storage→Input+Response
|
||||
StaticEmbedder pretrained word embedding / TF-IDF fallback
|
||||
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
|
||||
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding → TF-IDF (fallback)
|
||||
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
|
||||
├── Keep → timeline → chronologically sorted → system prompt
|
||||
└── Low score → Document layer archive (original timestamp)
|
||||
Save: 5s debounce write to disk
|
||||
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
↓ Prune archive ↑ LLM active recall
|
||||
|
||||
② Document (File Memory)
|
||||
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex)
|
||||
DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
|
||||
Read:
|
||||
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
|
||||
@ -132,11 +132,22 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
|
||||
→ triples → GraphDB.Commit
|
||||
```
|
||||
|
||||
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
|
||||
### Vectorization: Unified Multimodal Space (primary) → Word Embedding → TF-IDF (fallback)
|
||||
|
||||
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
|
||||
error and never pretends to succeed**:
|
||||
|
||||
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
|
||||
**① Unified multimodal space (primary path, since v1.2.0)**
|
||||
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
|
||||
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
|
||||
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
|
||||
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
|
||||
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
|
||||
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
|
||||
|
||||
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
|
||||
|
||||
**Model sources** (aligned 300d)
|
||||
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
|
||||
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
|
||||
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
|
||||
@ -196,28 +207,32 @@ single typo silently breaks reference binding with no error anywhere in the chai
|
||||
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
|
||||
nowhere to attach them.
|
||||
|
||||
### Media Memory (since v1.1.0)
|
||||
### Media Memory (since v1.2.0: first-class memory blocks)
|
||||
|
||||
`internal/memory/media/` — `Store`, content-addressed (CAS)
|
||||
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
|
||||
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
|
||||
structural edges (e.g. `sentence --contains--> block`) express ownership.
|
||||
|
||||
| Concern | Approach | Why |
|
||||
|---|---|---|
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
|
||||
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
|
||||
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
|
||||
| References | `owner_kind/owner_id/digest` composite primary key, `AddRef` idempotent | Three owner kinds: `context` (context events), `document`, `graph_sentence` |
|
||||
| GC | Two-stage with `minAge`, **referenced items are never deleted** | Description text stays in the text layers while blobs may be evicted — semantic memory and byte cache are decoupled |
|
||||
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
|
||||
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
|
||||
|
||||
**How media is represented in plain-text memory** is the marker `[<mime> <short digest>] <description>`:
|
||||
**Description-based indexing is gone**: the old implementation embedded a
|
||||
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
|
||||
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
|
||||
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
|
||||
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
|
||||
unified space, and no media marker is written into the body.
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||||
```
|
||||
|
||||
Why it must ride on text: `Doc.Content`, `sentences.text` and text memory's `Input` are all strings
|
||||
— there is no field to carry structured data. **The description text is the durable semantic
|
||||
memory** (retrieval uses it); the digest is the key back to the bytes (reverse lookup uses it).
|
||||
After capacity GC evicts a blob, the description remains in the L0/L2/L3 text.
|
||||
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
|
||||
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
|
||||
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
|
||||
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
|
||||
another model's vector**.
|
||||
|
||||
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
|
||||
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
|
||||
@ -292,7 +307,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
|
||||
| Method | Registration Mechanism | Compilation | Usage |
|
||||
|--------|----------------------|-------------|-------|
|
||||
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build` → `plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
|
||||
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
|
||||
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
|
||||
|
||||
@ -311,7 +326,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
|
||||
| Dimension | Built-in Plugin | External Plugin |
|
||||
|-----------|----------------|-----------------|
|
||||
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `hmapdev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
|
||||
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
|
||||
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
|
||||
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |
|
||||
|
||||
@ -29,75 +29,80 @@ type Plugin interface {
|
||||
|
||||
| Method | Use Case | Complexity |
|
||||
|--------|----------|------------|
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
|
||||
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
|
||||
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
|
||||
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 1. Quick Start: Using the plugindev Toolchain
|
||||
## 1. Quick Start: Using the hmapdev Toolchain
|
||||
|
||||
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
|
||||
`hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
|
||||
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
|
||||
|
||||
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
|
||||
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# Add plugindev to PATH or use directly
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# Add hmapdev to PATH or use directly
|
||||
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
|
||||
```
|
||||
|
||||
### SDK Version Management
|
||||
|
||||
`plugindev sdk` manages local SDK versions:
|
||||
`hmapdev sdk` manages local SDK versions:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # list installed SDK versions
|
||||
plugindev sdk current # show current SDK version
|
||||
plugindev sdk latest # show latest available version
|
||||
plugindev sdk install v0.8.0 # install a specific version
|
||||
plugindev sdk use v0.8.0 # switch to a version
|
||||
plugindev sdk path # show current SDK path
|
||||
hmapdev sdk list # list installed SDK versions
|
||||
hmapdev sdk current # show current SDK version
|
||||
hmapdev sdk latest # show latest available version
|
||||
hmapdev sdk install v1.2.0 # install a specific version
|
||||
hmapdev sdk use v1.2.0 # switch to a version
|
||||
hmapdev sdk path # show current SDK path
|
||||
```
|
||||
|
||||
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
|
||||
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
|
||||
|
||||
### Source Debugging
|
||||
|
||||
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir defaults to the current directory
|
||||
hmapdev debug [dir] # dir defaults to the current directory
|
||||
```
|
||||
|
||||
### Creating a Go Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# Edit plugin code
|
||||
vim plugin.go
|
||||
# Build and package (default is a multi-platform bundle, see below)
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myplugin_bundle.hmap
|
||||
# Single-platform build:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
|
||||
```
|
||||
|
||||
### Creating a Lua Plugin
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# Edit plugin code
|
||||
vim main.lua
|
||||
# Local test
|
||||
lua main.lua
|
||||
# Build and package
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# Output: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -128,16 +133,16 @@ myluaplugin/
|
||||
|
||||
### Build & Package
|
||||
|
||||
`plugindev build` automatically handles compilation and packaging:
|
||||
`hmapdev build` automatically handles compilation and packaging:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # default bundle mode (multi-platform)
|
||||
plugindev build --no-bundle # single-target build (per plg.json targets)
|
||||
plugindev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
plugindev build --outdir dist # output directory (default: dist)
|
||||
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
hmapdev build # default bundle mode (multi-platform)
|
||||
hmapdev build --no-bundle # single-target build (per plg.json targets)
|
||||
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
|
||||
hmapdev build --outdir dist # output directory (default: dist)
|
||||
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
|
||||
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
|
||||
```
|
||||
|
||||
Execution process:
|
||||
@ -171,7 +176,7 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
|
||||
> skips legacy artifacts with an actionable error instead of crashing.
|
||||
> - **Business code needs no changes** — the public SDK interface is unchanged; just
|
||||
> rebuild with the new `plugindev`.
|
||||
> rebuild with the new `hmapdev` (formerly `plugindev`).
|
||||
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
|
||||
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
|
||||
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
|
||||
@ -180,12 +185,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
|
||||
|
||||
### Build Targets & Multi-platform Bundle
|
||||
|
||||
**`plugindev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
**`hmapdev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
|
||||
|
||||
```bash
|
||||
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # explicitly enable bundle (same as above)
|
||||
plugindev build --no-bundle # disable bundle, build per plg.json targets
|
||||
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # explicitly enable bundle (same as above)
|
||||
hmapdev build --no-bundle # disable bundle, build per plg.json targets
|
||||
```
|
||||
|
||||
Notes:
|
||||
@ -275,7 +280,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### Entry Point
|
||||
|
||||
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
|
||||
which is the entry point when the kernel loads the plugin:
|
||||
|
||||
```go
|
||||
@ -284,7 +289,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
At build time, `plugindev build` auto-generates subprocess runtime code
|
||||
At build time, `hmapdev build` auto-generates subprocess runtime code
|
||||
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
|
||||
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
|
||||
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on
|
||||
|
||||
@ -90,8 +90,8 @@ eventLoop() → processTextInput()
|
||||
RelevanceContext — 内存 events[] + JSON持久化
|
||||
Append: 每次输入, CleanText → 三分支向量(textForVector)
|
||||
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
|
||||
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
|
||||
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
|
||||
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
|
||||
├── 保留 → timeline → 按时间排序 → system prompt
|
||||
└── 低分 → Document 层归档 (原始时间戳)
|
||||
Save: 5s debounce 写盘
|
||||
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
|
||||
↓ Prune 归档 ↑ LLM 主动召回
|
||||
|
||||
② Document (文件记忆)
|
||||
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex)
|
||||
DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex)
|
||||
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
|
||||
读取:
|
||||
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
|
||||
@ -132,11 +132,20 @@ eventLoop() → processTextInput()
|
||||
→ 三元组 → GraphDB.Commit
|
||||
```
|
||||
|
||||
### 向量化:预训练词嵌入 + TF-IDF 回退
|
||||
### 向量化:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)
|
||||
|
||||
所有向量化统一使用 `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**:
|
||||
|
||||
**主策略 — 预训练词嵌入(词对齐 300 维)**
|
||||
**① 统一多模态空间(主路径,v1.2.0 起)**
|
||||
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`:512 维、Apache-2.0、中文原生;
|
||||
亦可选 `qwen3vl` 或外部 `http` provider)。provider 经 `pkg/embedding` 公共 SPI 注册,
|
||||
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`),
|
||||
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
|
||||
(跨坐标系的向量混进去会算出两边都不像的方向)。
|
||||
|
||||
**② 词嵌入(文本兜底)** — `StaticEmbedder`(`internal/memory/static_embedder.go`):
|
||||
|
||||
**模型来源**(词对齐 300 维)
|
||||
- 模型来源:ConceptNet Numberbatch(77 语对齐)/ fastText 中文 / fastText 英文
|
||||
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
|
||||
- 路径名含 `numberbatch` → 自动下载 ConceptNet,含 `cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
|
||||
@ -194,30 +203,31 @@ eventLoop() → processTextInput()
|
||||
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
|
||||
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
|
||||
|
||||
### 媒体记忆(v1.1.0 起)
|
||||
### 媒体记忆(v1.2.0 起:一等记忆块)
|
||||
|
||||
`internal/memory/media/` — `Store`,内容寻址(CAS)
|
||||
媒体不是外挂内容,而是**记忆的一等节点**:`internal/memory/media/` 是内容寻址仓储(CAS),
|
||||
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
|
||||
|
||||
| 关注点 | 做法 | 为何 |
|
||||
|---|---|---|
|
||||
| 寻址 | sha256 digest,元数据在 SQLite、blob 在磁盘 | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 寻址 | sha256 digest;元数据在 SQLite,blob 在磁盘(`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份;元数据要可查询,blob 不该进数据库 |
|
||||
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
|
||||
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
|
||||
| 引用 | `owner_kind/owner_id/digest` 三元组主键,`AddRef` 幂等 | 三个 owner 类型:`context`(上下文事件)、`document`(文档)、`graph_sentence`(图谱句子) |
|
||||
| GC | 两阶段 + `minAge`,**有引用者绝不删** | 描述文本留在文本层,blob 可淘汰——语义记忆与字节缓存分离 |
|
||||
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
|
||||
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
|
||||
|
||||
**媒体在纯文本记忆里的表示**是标记 `[<mime> <短digest>] <描述>`:
|
||||
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
|
||||
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
|
||||
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
|
||||
正文里不再有 media marker。
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
```
|
||||
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
|
||||
`reembedStaleMedia()` 把 `vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
|
||||
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`,
|
||||
**绝不拿别的模型的向量顶替**。
|
||||
|
||||
之所以必须借文本承载:`Doc.Content`、`sentences.text`、文本记忆的 `Input` 全是字符串,
|
||||
没有字段能挂结构化数据。**描述文本才是持久的语义记忆**(检索靠它),digest 是回到字节的
|
||||
钥匙(反查靠它)。blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里。
|
||||
|
||||
媒体存储**全程可选**:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为
|
||||
纯文本行为,不报错不 panic。
|
||||
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为,
|
||||
不报错不 panic。
|
||||
|
||||
### 其他记忆层
|
||||
|
||||
@ -287,7 +297,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
|
||||
| 方式 | 注册机制 | 编译 | 用途 |
|
||||
|------|----------|------|------|
|
||||
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build` → `plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
|
||||
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
|
||||
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
|
||||
|
||||
@ -306,7 +316,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
|
||||
| 维度 | 内置插件 | 外部插件 |
|
||||
|------|----------|----------|
|
||||
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo),内核 spawn 为子进程 |
|
||||
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap` 包(ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
|
||||
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件(name, version, entry, platforms, capabilities 等) |
|
||||
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
|
||||
|
||||
@ -30,75 +30,80 @@ type Plugin interface {
|
||||
|
||||
| 方式 | 适用场景 | 复杂度 |
|
||||
|------|---------|--------|
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
|
||||
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
|
||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
|
||||
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
|
||||
|
||||
---
|
||||
|
||||
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
|
||||
|
||||
## 一、快速开始:使用 plugindev 工具链
|
||||
## 一、快速开始:使用 hmapdev 工具链
|
||||
|
||||
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型。
|
||||
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型,
|
||||
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
|
||||
|
||||
> 改名说明:1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`;SDK 存储目录同时由
|
||||
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
cd homeagent-sdk/tools/plugindev
|
||||
go build -o plugindev
|
||||
# 将 plugindev 加入 PATH 或直接使用
|
||||
cd homeagent-sdk/tools/hmapdev
|
||||
go build -o hmapdev
|
||||
# 将 hmapdev 加入 PATH 或直接使用
|
||||
# 也可从 SDK 的 release 附件下载预编译二进制(hmapdev_linux_amd64 等)
|
||||
```
|
||||
|
||||
### SDK 版本管理
|
||||
|
||||
`plugindev sdk` 子命令管理本地 SDK 版本:
|
||||
`hmapdev sdk` 子命令管理本地 SDK 版本:
|
||||
|
||||
```bash
|
||||
plugindev sdk list # 列出已安装的 SDK 版本
|
||||
plugindev sdk current # 显示当前使用的 SDK 版本
|
||||
plugindev sdk latest # 显示最新可用版本
|
||||
plugindev sdk install v0.8.0 # 安装指定版本
|
||||
plugindev sdk use v0.8.0 # 切换使用版本
|
||||
plugindev sdk path # 显示当前 SDK 路径
|
||||
hmapdev sdk list # 列出已安装的 SDK 版本
|
||||
hmapdev sdk current # 显示当前使用的 SDK 版本
|
||||
hmapdev sdk latest # 显示最新可用版本
|
||||
hmapdev sdk install v1.2.0 # 安装指定版本
|
||||
hmapdev sdk use v1.2.0 # 切换使用版本
|
||||
hmapdev sdk path # 显示当前 SDK 路径
|
||||
```
|
||||
|
||||
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/`,`plugindev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/`,`hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`。
|
||||
|
||||
### 源码调试
|
||||
|
||||
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
|
||||
|
||||
```bash
|
||||
plugindev debug [dir] # dir 默认当前目录
|
||||
hmapdev debug [dir] # dir 默认当前目录
|
||||
```
|
||||
|
||||
### 创建 Go 插件
|
||||
|
||||
```bash
|
||||
plugindev init myplugin
|
||||
hmapdev init myplugin
|
||||
cd myplugin
|
||||
# 编辑插件代码
|
||||
vim plugin.go
|
||||
# 编译打包
|
||||
plugindev build # 默认多平台 bundle(见下节)
|
||||
hmapdev build # 默认多平台 bundle(见下节)
|
||||
# 输出: dist/myplugin_bundle.hmap
|
||||
# 单平台构建:
|
||||
plugindev build --no-bundle
|
||||
hmapdev build --no-bundle
|
||||
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
|
||||
```
|
||||
|
||||
### 创建 Lua 插件
|
||||
|
||||
```bash
|
||||
plugindev init myluaplugin --lua
|
||||
hmapdev init myluaplugin --lua
|
||||
cd myluaplugin
|
||||
# 编辑插件代码
|
||||
vim main.lua
|
||||
# 本地测试
|
||||
lua main.lua
|
||||
# 编译打包
|
||||
plugindev build
|
||||
hmapdev build
|
||||
# 输出: dist/myluaplugin_lua.hmap
|
||||
```
|
||||
|
||||
@ -129,16 +134,16 @@ myluaplugin/
|
||||
|
||||
### 编译打包
|
||||
|
||||
`plugindev build` 会自动完成编译和打包:
|
||||
`hmapdev build` 会自动完成编译和打包:
|
||||
|
||||
```bash
|
||||
cd myplugin
|
||||
plugindev build # 默认 bundle 模式(多平台合集)
|
||||
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
plugindev build --outdir dist # 指定输出目录(默认 dist)
|
||||
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
hmapdev build # 默认 bundle 模式(多平台合集)
|
||||
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets)
|
||||
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
|
||||
hmapdev build --outdir dist # 指定输出目录(默认 dist)
|
||||
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace)
|
||||
hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
|
||||
```
|
||||
|
||||
执行过程:
|
||||
@ -154,7 +159,7 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
|
||||
| 文件 | 用途 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"`);`bundle` — 多平台合集开关(默认 `true`)|
|
||||
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
|
||||
|
||||
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**:
|
||||
|
||||
@ -169,7 +174,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
>
|
||||
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
|
||||
> 会跳过并报可操作错误,不崩溃。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
|
||||
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`)重编。
|
||||
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
|
||||
> 它现在只用于区分 Lua 插件。
|
||||
> - 产物不再需要 cgo,交叉编译无需目标平台 C 工具链。
|
||||
@ -178,12 +183,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
|
||||
|
||||
### 构建目标与多平台打包(bundle)
|
||||
|
||||
**`plugindev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
**`hmapdev build` 默认就是 bundle 模式**(`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64,生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
|
||||
|
||||
```bash
|
||||
plugindev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
plugindev build --bundle # 显式开启 bundle(同上)
|
||||
plugindev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
hmapdev build # 默认 bundle,输出 dist/myplugin_bundle.hmap
|
||||
hmapdev build --bundle # 显式开启 bundle(同上)
|
||||
hmapdev build --no-bundle # 关闭 bundle,按 plg.json 的 targets 逐平台构建
|
||||
```
|
||||
|
||||
注意:
|
||||
@ -273,7 +278,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
|
||||
|
||||
### 入口点
|
||||
|
||||
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
|
||||
|
||||
```go
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
@ -281,7 +286,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
}
|
||||
```
|
||||
|
||||
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑,仅跨进程资源传递机制不同(Unix 继承 fd,Windows 命名内核对象)。
|
||||
|
||||
### PluginSDK 核心 API
|
||||
|
||||
|
||||
@ -336,6 +336,10 @@ func main() {
|
||||
if err := docStore.Start(); err != nil {
|
||||
log.Printf("[homed] warning: document store: %v", err)
|
||||
}
|
||||
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
|
||||
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
defer docStore.Stop()
|
||||
|
||||
// 媒体存储(内容寻址):记忆块的内容后端。
|
||||
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
|
||||
@ -363,7 +367,11 @@ func main() {
|
||||
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
|
||||
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
|
||||
var multimodalSpace vector.MultimodalEmbedder
|
||||
// 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段):
|
||||
// 「配了哪个 provider」与「为什么没启用」,避免只能看到 false 却不知原因。
|
||||
var mmProviderName, mmErr string
|
||||
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
|
||||
mmProviderName = mmProvider
|
||||
opts := map[string]string{}
|
||||
const optPrefix = "core.memory.multimodal_space.options."
|
||||
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
|
||||
@ -371,10 +379,12 @@ func main() {
|
||||
}
|
||||
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
|
||||
if err != nil {
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v(多模态向量检索已禁用;已注册: %s)",
|
||||
mmProvider, err, strings.Join(embedding.Names(), ", "))
|
||||
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
|
||||
provider.Close()
|
||||
mmErr = err.Error()
|
||||
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v(多模态向量检索已禁用)", mmProvider, err)
|
||||
} else {
|
||||
multimodalSpace = adapted
|
||||
@ -401,13 +411,29 @@ func main() {
|
||||
// 人格设定
|
||||
// ========================================================================
|
||||
|
||||
// 人格来源优先级:personal/personal.md(高级覆盖,存在且非空才生效)
|
||||
// > 配置项 core.agent.personal_prompt(默认模板 = config.DefaultPersonaPrompt)。
|
||||
//
|
||||
// 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、
|
||||
// 反过来让实例自称旧版本(v1.2.0 压测发现)。故:
|
||||
// - 配置项化 + 内置默认模板(不含版本号字面量)
|
||||
// - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警
|
||||
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
|
||||
personality, err := agentPkg.LoadPersonality(personalPath)
|
||||
if err != nil {
|
||||
log.Printf("[homed] warning: load personality: %v", err)
|
||||
}
|
||||
if personality != nil && personality.Content != "" {
|
||||
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
|
||||
log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content))
|
||||
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
|
||||
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
|
||||
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
|
||||
}
|
||||
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
|
||||
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
|
||||
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv))
|
||||
} else {
|
||||
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@ -486,30 +512,39 @@ func main() {
|
||||
}
|
||||
|
||||
agent := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
ID: "main",
|
||||
SystemPrompt: sysPrompt,
|
||||
Provider: provider,
|
||||
ProviderManager: providerMgr,
|
||||
IO: iom,
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
MediaStore: mediaStore,
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||
DataDir: cfg.Daemon.DataDir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
MultimodalSpace: multimodalSpace,
|
||||
EmbeddingProvider: mmProviderName,
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
|
||||
@ -24,7 +24,7 @@ import (
|
||||
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
|
||||
// WSL2 里就是普通 linux/amd64,走与我们测试矩阵完全相同的那条路径。
|
||||
//
|
||||
// 注意范围:只有 homed 如此。plugindev 工具链仍可在 Windows 上运行
|
||||
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
|
||||
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
|
||||
func requireSupportedPlatform() {
|
||||
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "com.example.homeagent",
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": 1001001,
|
||||
"versionName": "1.1.1",
|
||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||
"icon": "$media:layered_image",
|
||||
|
||||
@ -32,6 +32,10 @@ export class ApiClient {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
clearConnection(): void {
|
||||
this.conn = null;
|
||||
}
|
||||
|
||||
getConnection(): ConnectionConfig | null {
|
||||
return this.conn;
|
||||
}
|
||||
|
||||
@ -4,16 +4,63 @@ import { pasteboard } from '@kit.BasicServicesKit';
|
||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||
import { componentSnapshot } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
||||
|
||||
// ===== 能力结果 =====
|
||||
|
||||
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
|
||||
export const LOCAL_DEVICE_CAPS: string[] = [
|
||||
'status',
|
||||
'deviceinfo',
|
||||
'screensee',
|
||||
'screensue',
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
];
|
||||
|
||||
export interface CapResult {
|
||||
status: string; // 'ok' | 'error'
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DeviceStatusPayload {
|
||||
device_id: string;
|
||||
status: string;
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
interface DeviceDetails {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
brand: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
series: string;
|
||||
sdk_api_version: number;
|
||||
security_patch: string;
|
||||
abi_list: string;
|
||||
device_type: string;
|
||||
}
|
||||
|
||||
interface DeviceInfoPayload {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
caps: string[];
|
||||
info: DeviceDetails;
|
||||
}
|
||||
|
||||
const APP_STARTED_AT: number = Date.now();
|
||||
|
||||
function okResult(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||
return r;
|
||||
@ -44,53 +91,79 @@ async function captureScreenPixelMap(): Promise<image.PixelMap> {
|
||||
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
||||
*/
|
||||
export async function capScreensee(): Promise<CapResult> {
|
||||
let full: image.PixelMap | null = null;
|
||||
let packer: image.ImagePacker | null = null;
|
||||
try {
|
||||
const full: image.PixelMap = await captureScreenPixelMap();
|
||||
full = await captureScreenPixelMap();
|
||||
const info: image.ImageInfo = await full.getImageInfo();
|
||||
const maxW: number = 720;
|
||||
let targetW: number = info.size.width;
|
||||
let targetH: number = info.size.height;
|
||||
if (targetW > maxW) {
|
||||
targetH = Math.floor(targetH * maxW / targetW);
|
||||
targetW = maxW;
|
||||
const maxW: number = 420;
|
||||
const maxH: number = 640;
|
||||
let scale: number = 1;
|
||||
if (info.size.width > maxW) {
|
||||
scale = maxW / info.size.width;
|
||||
}
|
||||
let packed: ArrayBuffer;
|
||||
if (targetW !== info.size.width) {
|
||||
await full.scale(targetW / info.size.width, targetH / info.size.height);
|
||||
if (info.size.height * scale > maxH) {
|
||||
scale = maxH / info.size.height;
|
||||
}
|
||||
const packer: image.ImagePacker = image.createImagePacker();
|
||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
|
||||
packed = await packer.packing(full, opt);
|
||||
packer.release();
|
||||
full.release();
|
||||
if (scale < 1) {
|
||||
await full.scale(scale, scale);
|
||||
}
|
||||
packer = image.createImagePacker();
|
||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
|
||||
const packed: ArrayBuffer = await packer.packing(full, opt);
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
||||
if (b64.length > 950000) {
|
||||
return errResult('当前画面数据过大,请稍后重试');
|
||||
}
|
||||
return okResult('data:image/jpeg;base64,' + b64);
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('screensee failed: ' + msg);
|
||||
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
|
||||
} finally {
|
||||
if (packer !== null) {
|
||||
packer.release();
|
||||
}
|
||||
if (full !== null) {
|
||||
full.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== clipboardsee / clipboardsue =====
|
||||
|
||||
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
||||
|
||||
/**
|
||||
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
|
||||
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
|
||||
*/
|
||||
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const result: PermissionRequestResult =
|
||||
await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS);
|
||||
return result.authResults.length > 0 && result.authResults[0] === 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
||||
// 说明:READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
|
||||
// 这里直接尝试读取;系统拒绝时回错误信息。
|
||||
const granted: boolean = await ensureClipboardPermission(context);
|
||||
if (!granted) {
|
||||
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
|
||||
}
|
||||
try {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const has: boolean = await clip.hasData();
|
||||
if (!has) {
|
||||
const empty: CapResult = { status: 'ok', output: '', error: '' };
|
||||
return empty;
|
||||
return okResult('');
|
||||
}
|
||||
const data: pasteboard.PasteData = await clip.getData();
|
||||
const txt: string = data.getPrimaryText();
|
||||
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
|
||||
return out;
|
||||
return okResult(txt ?? '');
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
|
||||
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,10 +172,9 @@ export async function capClipboardsue(text: string): Promise<CapResult> {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
||||
await clip.setPasteData(data);
|
||||
return okResult('written ' + text.length + ' chars');
|
||||
return okResult('clipboard written');
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsue failed: ' + msg);
|
||||
return errResult('剪贴板写入失败,请保持应用在前台后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@ -112,13 +184,11 @@ class TtsSession {
|
||||
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
||||
|
||||
async speak(text: string): Promise<CapResult> {
|
||||
if (text.length > 4000) {
|
||||
return errResult('朗读内容过长,请缩短到 4000 字以内');
|
||||
}
|
||||
try {
|
||||
if (this.engine === null) {
|
||||
const extra: Record<string, Object> = {
|
||||
'style': 'interaction-broadcast',
|
||||
'locate': 'CN',
|
||||
'name': 'EngineName',
|
||||
};
|
||||
const params: textToSpeech.CreateEngineParams = {
|
||||
language: 'zh-CN',
|
||||
person: 0,
|
||||
@ -133,8 +203,7 @@ class TtsSession {
|
||||
this.engine.speak(text, sp);
|
||||
return okResult('speaking');
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('speakeruse failed: ' + msg);
|
||||
return errResult('语音服务暂时不可用,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,20 +225,49 @@ export async function capSpeakerUse(text: string): Promise<CapResult> {
|
||||
return ttsSession.speak(text);
|
||||
}
|
||||
|
||||
// ===== deviceinfo =====
|
||||
export function shutdownSpeakerUse(): void {
|
||||
ttsSession.shutdown();
|
||||
}
|
||||
|
||||
export function capDeviceInfo(): CapResult {
|
||||
const lines: string[] = [];
|
||||
lines.push('brand=' + deviceInfo.brand);
|
||||
lines.push('manufacturer=' + deviceInfo.manufacture);
|
||||
lines.push('model=' + deviceInfo.productModel);
|
||||
lines.push('series=' + deviceInfo.productSeries);
|
||||
lines.push('osFullName=' + deviceInfo.osFullName);
|
||||
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
|
||||
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
|
||||
lines.push('abiList=' + deviceInfo.abiList);
|
||||
lines.push('deviceType=' + deviceInfo.deviceType);
|
||||
return okResult(lines.join('\n'));
|
||||
// ===== status / deviceinfo =====
|
||||
|
||||
export function capStatus(deviceId: string): CapResult {
|
||||
const payload: DeviceStatusPayload = {
|
||||
device_id: deviceId,
|
||||
status: 'online',
|
||||
hostname: 'ohos-phone',
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000),
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||
const details: DeviceDetails = {
|
||||
hostname: 'ohos-phone',
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
os_release: deviceInfo.osFullName,
|
||||
version: '1.1.1',
|
||||
cpus: 0,
|
||||
brand: deviceInfo.brand,
|
||||
manufacturer: deviceInfo.manufacture,
|
||||
model: deviceInfo.productModel,
|
||||
series: deviceInfo.productSeries,
|
||||
sdk_api_version: deviceInfo.sdkApiVersion,
|
||||
security_patch: deviceInfo.securityPatchTag,
|
||||
abi_list: deviceInfo.abiList,
|
||||
device_type: deviceInfo.deviceType,
|
||||
};
|
||||
const payload: DeviceInfoPayload = {
|
||||
device_id: deviceId,
|
||||
name: deviceName,
|
||||
kind: 'ohos-phone',
|
||||
caps: LOCAL_DEVICE_CAPS,
|
||||
info: details,
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
@ -182,14 +280,18 @@ export interface ScreensuePayload {
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
let rest: string = rawArgs.trim();
|
||||
const tokens: string[] = rest.split(/\s+/);
|
||||
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
|
||||
p.duration = parseInt(tokens[0], 10);
|
||||
rest = tokens.slice(1).join(' ');
|
||||
} else {
|
||||
rest = tokens.join(' ');
|
||||
const leadingSpaces: RegExp = new RegExp('^\\s+');
|
||||
const firstSpace: RegExp = new RegExp('\\s');
|
||||
let rest: string = rawArgs.replace(leadingSpaces, '');
|
||||
const splitAt: number = rest.search(firstSpace);
|
||||
if (splitAt > 0) {
|
||||
const first: string = rest.substring(0, splitAt);
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (digits.test(first)) {
|
||||
p.duration = Math.min(parseInt(first, 10), 86400);
|
||||
rest = rest.substring(splitAt).replace(leadingSpaces, '');
|
||||
}
|
||||
}
|
||||
p.content = rest.trim();
|
||||
p.content = rest;
|
||||
return p;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { deviceBridge, CmdReply } from './DeviceBridge';
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import {
|
||||
CapResult,
|
||||
capScreensee,
|
||||
@ -6,12 +6,14 @@ import {
|
||||
capClipboardsue,
|
||||
capSpeakerUse,
|
||||
capDeviceInfo,
|
||||
capStatus,
|
||||
parseScreensue,
|
||||
ScreensuePayload,
|
||||
} from './BridgeCaps';
|
||||
import { connStore } from './ConnStore';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
// screensue 展示回调由 UI 层注册(Index 挂全局悬浮层)
|
||||
// screensue 展示回调由根 UI 注册:窄屏整页,宽屏右侧内容栏。
|
||||
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
||||
|
||||
let screensueHandler: ScreensueHandler | null = null;
|
||||
@ -25,10 +27,23 @@ export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
|
||||
appContext = ctx;
|
||||
}
|
||||
|
||||
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
|
||||
/** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */
|
||||
function splitCapability(command: string): string[] {
|
||||
const cmd: string = command.trim();
|
||||
const idx: number = cmd.indexOf(' ');
|
||||
let start: number = 0;
|
||||
while (start < command.length && isCommandSpace(command.charAt(start))) {
|
||||
start = start + 1;
|
||||
}
|
||||
let cmd: string = command.substring(start);
|
||||
if (cmd.startsWith('homeagent-')) {
|
||||
cmd = cmd.substring('homeagent-'.length);
|
||||
}
|
||||
let idx: number = -1;
|
||||
for (let i: number = 0; i < cmd.length; i++) {
|
||||
if (isCommandSpace(cmd.charAt(i))) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx < 0) {
|
||||
return [cmd];
|
||||
}
|
||||
@ -36,51 +51,73 @@ function splitCapability(command: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCommandSpace(ch: string): boolean {
|
||||
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
}
|
||||
|
||||
function hasArgs(args: string): boolean {
|
||||
return args.trim().length > 0;
|
||||
}
|
||||
|
||||
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
||||
const parts: string[] = splitCapability(command);
|
||||
const name: string = parts[0];
|
||||
const args: string = parts.length > 1 ? parts[1] : '';
|
||||
|
||||
// screensee 截屏回传(data URL 走文本结果,服务端兼容)
|
||||
if (name === 'screensee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('screensee 不接受额外参数');
|
||||
}
|
||||
return capScreensee();
|
||||
}
|
||||
if (name === 'screensue') {
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('screensue 需要展示内容');
|
||||
}
|
||||
const payload: ScreensuePayload = parseScreensue(args);
|
||||
if (payload.content.length === 0) {
|
||||
return errRes('screensue 需要展示内容');
|
||||
}
|
||||
if (screensueHandler !== null) {
|
||||
screensueHandler(payload);
|
||||
return okRes('shown');
|
||||
return okRes('内容已显示');
|
||||
}
|
||||
return errRes('screensue: display layer not ready');
|
||||
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
if (name === 'clipboardsee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('clipboardsee 不接受额外参数');
|
||||
}
|
||||
if (appContext === null) {
|
||||
return errRes('clipboardsee: app context missing');
|
||||
return errRes('应用界面尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
return capClipboardSee(appContext);
|
||||
}
|
||||
if (name === 'clipboardsue') {
|
||||
if (args.length === 0) {
|
||||
return errRes('clipboardsue: empty text');
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('clipboardsue 需要写入文字');
|
||||
}
|
||||
return capClipboardsue(args);
|
||||
}
|
||||
if (name === 'speakeruse') {
|
||||
if (args.length === 0) {
|
||||
return errRes('speakeruse: empty text');
|
||||
if (!hasArgs(args)) {
|
||||
return errRes('speakeruse 需要朗读文字');
|
||||
}
|
||||
return capSpeakerUse(args);
|
||||
}
|
||||
if (name === 'deviceinfo' || name === 'status') {
|
||||
return capDeviceInfo();
|
||||
if (name === 'status') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('status 不接受额外参数');
|
||||
}
|
||||
return capStatus(connStore.getDeviceId());
|
||||
}
|
||||
if (name === 'camerasue') {
|
||||
return errRes('camerasue: camera capture not supported on this build');
|
||||
if (name === 'deviceinfo') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('deviceinfo 不接受额外参数');
|
||||
}
|
||||
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
|
||||
}
|
||||
if (name === 'computeruse') {
|
||||
return errRes('computeruse: not applicable to touch-only device');
|
||||
}
|
||||
return errRes('unsupported homeagent capability: ' + name);
|
||||
return errRes('不支持的本机能力:' + name);
|
||||
}
|
||||
|
||||
function okRes(output: string): CapResult {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { webSocket } from '@kit.NetworkKit';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { CapResult } from './BridgeCaps';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
@ -33,13 +32,6 @@ interface BindMessage {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface CmdMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
command: string;
|
||||
cmd_type: string;
|
||||
}
|
||||
|
||||
export interface CmdReply {
|
||||
op: string; // 'cmd_result'
|
||||
req_id: string;
|
||||
@ -81,14 +73,15 @@ export class DeviceBridgeClient {
|
||||
private caps: string[] = [];
|
||||
private hostname: string = 'ohos';
|
||||
private connected: boolean = false;
|
||||
private everConnected: boolean = false;
|
||||
private bound: boolean = false;
|
||||
private manualClose: boolean = false;
|
||||
private reconnectTimer: number = -1;
|
||||
private connectionGeneration: number = 0;
|
||||
private cmdHandler: BridgeCmdHandler | null = null;
|
||||
private onStateChange: ((open: boolean) => void) | null = null;
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
return this.connected && this.bound;
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
@ -117,37 +110,40 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
|
||||
private async openAndRegister(authorized: boolean): Promise<void> {
|
||||
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
this.ws.off('close');
|
||||
this.ws.off('error');
|
||||
this.ws.close().catch(() => {
|
||||
// ignore stale socket close failure
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
// ignore stale socket cleanup failure
|
||||
}
|
||||
this.ws = webSocket.createWebSocket();
|
||||
this.bindWsEvents(authorized);
|
||||
// 鉴权必须走请求头,不能拼 ?token= :
|
||||
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
|
||||
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer,
|
||||
// 查询参数一律视为未授权 → 握手被 401 顶掉,
|
||||
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
|
||||
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token;
|
||||
// 如果我们再带 ?token=<webui apiKey>,remotedevice 的 ServeWS
|
||||
// 会拿它和 ws_token 比对并 401。留空反而放行。
|
||||
this.connectionGeneration = this.connectionGeneration + 1;
|
||||
const generation: number = this.connectionGeneration;
|
||||
const socket: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
this.ws = socket;
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.bindWsEvents(socket, authorized, generation);
|
||||
const opts: webSocket.WebSocketRequestOptions = {
|
||||
header: this.authHeader(),
|
||||
};
|
||||
try {
|
||||
await this.ws.connect(this.url, opts);
|
||||
await socket.connect(this.url, opts);
|
||||
} catch (e) {
|
||||
this.connected = false;
|
||||
this.scheduleReconnect();
|
||||
if (generation === this.connectionGeneration && !this.manualClose) {
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 握手请求头:X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
|
||||
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
|
||||
private authHeader(): Record<string, string> {
|
||||
const h: Record<string, string> = {};
|
||||
if (this.token.length > 0) {
|
||||
@ -157,38 +153,48 @@ export class DeviceBridgeClient {
|
||||
return h;
|
||||
}
|
||||
|
||||
private bindWsEvents(authorized: boolean): void {
|
||||
this.ws.on('open', (err: Error, value: Object) => {
|
||||
private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
|
||||
socket.on('open', (err: Error, value: Object) => {
|
||||
if (generation !== this.connectionGeneration || this.manualClose) {
|
||||
socket.close().catch(() => {
|
||||
// ignore stale socket close failure
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.connected = true;
|
||||
this.everConnected = true;
|
||||
this.cancelReconnect();
|
||||
this.bound = false;
|
||||
this.sendHello(authorized);
|
||||
this.sendBind();
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(true);
|
||||
}
|
||||
});
|
||||
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||
if (typeof value === 'string') {
|
||||
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||
if (generation === this.connectionGeneration && typeof value === 'string') {
|
||||
this.handleTextFrame(value);
|
||||
}
|
||||
});
|
||||
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||
this.handleSocketEnd(generation);
|
||||
});
|
||||
this.ws.on('error', (err: Error) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
socket.on('error', (err: Error) => {
|
||||
this.handleSocketEnd(generation);
|
||||
});
|
||||
}
|
||||
|
||||
private handleSocketEnd(generation: number): void {
|
||||
if (generation !== this.connectionGeneration) {
|
||||
return;
|
||||
}
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private notifyState(open: boolean): void {
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(open);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.manualClose || this.reconnectTimer >= 0) {
|
||||
return;
|
||||
@ -211,18 +217,20 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
|
||||
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
|
||||
updateAuthorized(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
if (this.connected) {
|
||||
if (this.connected && this.bound) {
|
||||
this.sendHello(authorized);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manualClose = true;
|
||||
this.connectionGeneration = this.connectionGeneration + 1;
|
||||
this.cancelReconnect();
|
||||
this.connected = false;
|
||||
this.bound = false;
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
@ -234,9 +242,7 @@ export class DeviceBridgeClient {
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.notifyState(false);
|
||||
}
|
||||
|
||||
private sendHello(authorized: boolean): void {
|
||||
@ -246,7 +252,7 @@ export class DeviceBridgeClient {
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.0',
|
||||
version: '1.1.1',
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
@ -280,37 +286,50 @@ export class DeviceBridgeClient {
|
||||
return;
|
||||
}
|
||||
const op: string = obj['op'] as string ?? '';
|
||||
if (op === 'cmd') {
|
||||
const reqId: string = obj['req_id'] as string ?? '';
|
||||
const command: string = obj['command'] as string ?? '';
|
||||
if (reqId.length === 0 || command.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastAuthorized) {
|
||||
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||
return;
|
||||
}
|
||||
this.dispatchCommand(reqId, command);
|
||||
} else if (op === 'hello_ack' || op === 'bind_ack') {
|
||||
if (this.onAck !== null) {
|
||||
this.onAck(op);
|
||||
if (op === 'bind_ack') {
|
||||
const accepted: boolean = obj['ok'] === true;
|
||||
if (accepted && this.connected && !this.manualClose) {
|
||||
this.bound = true;
|
||||
this.cancelReconnect();
|
||||
this.notifyState(true);
|
||||
} else {
|
||||
this.bound = false;
|
||||
this.notifyState(false);
|
||||
try {
|
||||
this.ws.close().catch(() => {
|
||||
// ignore bind rejection close failure
|
||||
});
|
||||
} catch (e) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (op !== 'cmd' || !this.bound) {
|
||||
return;
|
||||
}
|
||||
const reqId: string = obj['req_id'] as string ?? '';
|
||||
const command: string = obj['command'] as string ?? '';
|
||||
if (reqId.length === 0 || command.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastAuthorized) {
|
||||
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||
return;
|
||||
}
|
||||
this.dispatchCommand(reqId, command);
|
||||
}
|
||||
|
||||
onAck: ((op: string) => void) | null = null;
|
||||
|
||||
private dispatchCommand(reqId: string, command: string): void {
|
||||
if (this.cmdHandler === null) {
|
||||
this.sendResult(reqId, 'error', '', 'no capability handler registered');
|
||||
this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
|
||||
return;
|
||||
}
|
||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||
handler(reqId, command).then((res: CapResult) => {
|
||||
this.sendResult(reqId, res.status, res.output, res.error);
|
||||
}).catch((e: Object) => {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
this.sendResult(reqId, 'error', '', msg);
|
||||
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
||||
});
|
||||
}
|
||||
|
||||
@ -388,7 +407,3 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
|
||||
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
||||
|
||||
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
|
||||
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
|
||||
import { connStore } from './ConnStore';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
|
||||
export { LOCAL_DEVICE_CAPS } from './BridgeCaps';
|
||||
|
||||
let bridgeStarting: boolean = false;
|
||||
let foregroundActive: boolean = false;
|
||||
let rootUIReady: boolean = false;
|
||||
let bridgeGeneration: number = 0;
|
||||
let stateTrackingReady: boolean = false;
|
||||
|
||||
function ensureBridgeStateTracking(): void {
|
||||
if (stateTrackingReady) {
|
||||
return;
|
||||
}
|
||||
stateTrackingReady = true;
|
||||
AppStorage.setOrCreate<boolean>('deviceBridgeConnected', false);
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
AppStorage.set<boolean>('deviceBridgeConnected', open);
|
||||
});
|
||||
}
|
||||
|
||||
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
|
||||
export function deviceGatewayUrl(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
let scheme: string = 'ws://';
|
||||
let rest: string = trimmed;
|
||||
if (trimmed.startsWith('https://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('https://'.length);
|
||||
} else if (trimmed.startsWith('http://')) {
|
||||
rest = trimmed.substring('http://'.length);
|
||||
} else if (trimmed.startsWith('wss://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('wss://'.length);
|
||||
} else if (trimmed.startsWith('ws://')) {
|
||||
rest = trimmed.substring('ws://'.length);
|
||||
}
|
||||
return scheme + rest + '/api/v1/device/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab,
|
||||
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
|
||||
*/
|
||||
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
foregroundActive = true;
|
||||
setBridgeAppContext(context);
|
||||
installCmdRouter();
|
||||
ensureBridgeStateTracking();
|
||||
if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) {
|
||||
return;
|
||||
}
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) {
|
||||
return;
|
||||
}
|
||||
bridgeStarting = true;
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
const generation: number = bridgeGeneration;
|
||||
const deviceId: string = connStore.ensureDeviceId();
|
||||
try {
|
||||
await deviceBridge.connect(
|
||||
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
|
||||
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
|
||||
if (!foregroundActive || generation !== bridgeGeneration) {
|
||||
deviceBridge.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
// DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。
|
||||
}
|
||||
if (generation === bridgeGeneration) {
|
||||
bridgeStarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */
|
||||
export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void {
|
||||
rootUIReady = true;
|
||||
startForegroundBridge(context);
|
||||
}
|
||||
|
||||
/** 后台不接受需要前台 UI/剪贴板授权的命令。 */
|
||||
export function stopForegroundBridge(): void {
|
||||
foregroundActive = false;
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
bridgeStarting = false;
|
||||
shutdownSpeakerUse();
|
||||
deviceBridge.disconnect();
|
||||
}
|
||||
|
||||
/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */
|
||||
export async function restartForegroundBridge(context: common.UIAbilityContext): Promise<void> {
|
||||
bridgeGeneration = bridgeGeneration + 1;
|
||||
bridgeStarting = false;
|
||||
deviceBridge.disconnect();
|
||||
await startForegroundBridge(context);
|
||||
}
|
||||
@ -67,8 +67,11 @@ class StatusStore {
|
||||
this.fail(noConnectionMessage());
|
||||
return;
|
||||
}
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, true);
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
|
||||
// init() 已把所有键种好,刷新阶段必须用 set,否则摘要卡会永远停在
|
||||
// 版本 "-"、插件 0 的初始状态。
|
||||
AppStorage.set<boolean>(K_LOADING, true);
|
||||
AppStorage.set<string>(K_ERR, '');
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/status', 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
@ -79,10 +82,10 @@ class StatusStore {
|
||||
const agents: number = obj['agents'] as number ?? 0;
|
||||
const startedAt: string = obj['startedAt'] as string ?? '';
|
||||
|
||||
AppStorage.setOrCreate<boolean>(K_UP, true);
|
||||
AppStorage.setOrCreate<string>(K_VERSION, version);
|
||||
AppStorage.setOrCreate<number>(K_AGENTS, agents);
|
||||
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
|
||||
AppStorage.set<boolean>(K_UP, true);
|
||||
AppStorage.set<string>(K_VERSION, version);
|
||||
AppStorage.set<number>(K_AGENTS, agents);
|
||||
AppStorage.set<string>(K_STARTED, startedAt);
|
||||
|
||||
const systemFields: StatField[] = [
|
||||
{ label: '版本', value: version },
|
||||
@ -103,7 +106,7 @@ class StatusStore {
|
||||
} catch (e) {
|
||||
this.fail(userMessage('status.refresh', e));
|
||||
}
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
}
|
||||
|
||||
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
||||
@ -119,8 +122,8 @@ class StatusStore {
|
||||
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
||||
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
||||
|
||||
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
|
||||
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
|
||||
AppStorage.set<number>(K_PLUGINS, pluginCount);
|
||||
AppStorage.set<number>(K_TOOLS, toolCount);
|
||||
|
||||
const kernelFields: StatField[] = [
|
||||
{ label: 'Agent ID', value: agentId },
|
||||
@ -219,9 +222,9 @@ class StatusStore {
|
||||
}
|
||||
|
||||
private fail(msg: string): void {
|
||||
AppStorage.setOrCreate<string>(K_ERR, msg);
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
AppStorage.set<string>(K_ERR, msg);
|
||||
AppStorage.set<boolean>(K_UP, false);
|
||||
AppStorage.set<boolean>(K_LOADING, false);
|
||||
this.groups = [];
|
||||
this.bump();
|
||||
}
|
||||
@ -229,7 +232,7 @@ class StatusStore {
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
private bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
||||
AppStorage.setOrCreate<number>(K_REV, cur + 1);
|
||||
AppStorage.set<number>(K_REV, cur + 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants';
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
import { MotionBase } from './MotionBase';
|
||||
|
||||
/**
|
||||
* agent 主动推送的前台内容页。
|
||||
*
|
||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||
*/
|
||||
@Component
|
||||
export struct ScreensuePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop pushedText: string = '';
|
||||
@Prop countdown: number = 0;
|
||||
onClose: () => void = () => {
|
||||
};
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
GradientBackground()
|
||||
|
||||
Scroll() {
|
||||
Column({ space: 14 }) {
|
||||
Row({ space: 8 }) {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.palette().accent)
|
||||
Text('agent 推送')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
if (this.countdown > 0) {
|
||||
Text(this.countdown.toString() + 's')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
} else {
|
||||
Text('常驻')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Column() {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, top: 82, bottom: 96 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
|
||||
PageTopBar({ title: '推送内容' })
|
||||
|
||||
Row() {
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('关闭')
|
||||
.height(42)
|
||||
.padding({ left: 22, right: 22 })
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(Color.White)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.borderRadius(21)
|
||||
.onClick(() => {
|
||||
this.onClose();
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 18, right: 18, bottom: 22 })
|
||||
.justifyContent(FlexAlign.End)
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: 18 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(this.palette().bgPrimary)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,11 @@ import { GradientBackground } from './GradientBackground';
|
||||
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
||||
|
||||
export function markSubPageOpen(open: boolean): void {
|
||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
|
||||
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
} else {
|
||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
}
|
||||
}
|
||||
|
||||
/** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */
|
||||
|
||||
@ -4,6 +4,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
|
||||
import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
|
||||
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
|
||||
function storedThemeMode(): string {
|
||||
@ -31,6 +32,7 @@ export default class EntryAbility extends UIAbility {
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
stopForegroundBridge();
|
||||
console.info('[HomeAgent] ability onDestroy');
|
||||
}
|
||||
|
||||
@ -86,6 +88,7 @@ export default class EntryAbility extends UIAbility {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.reapplyStoredTheme();
|
||||
startForegroundBridge(this.context);
|
||||
startUI();
|
||||
}).catch(() => {
|
||||
startUI();
|
||||
@ -107,6 +110,7 @@ export default class EntryAbility extends UIAbility {
|
||||
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
||||
this.reapplyStoredTheme();
|
||||
this.applySystemBar();
|
||||
startForegroundBridge(this.context);
|
||||
startUI();
|
||||
}).catch((e: Error) => {
|
||||
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
||||
@ -129,10 +133,12 @@ export default class EntryAbility extends UIAbility {
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
startForegroundBridge(this.context);
|
||||
console.info('[HomeAgent] ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
stopForegroundBridge();
|
||||
console.info('[HomeAgent] ability onBackground');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +1,14 @@
|
||||
import { deviceBridge } from '../common/DeviceBridge';
|
||||
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
|
||||
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
|
||||
const LOCAL_CAPS: string[] = [
|
||||
'status',
|
||||
'deviceinfo',
|
||||
'screensee',
|
||||
'screensue',
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
];
|
||||
import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession';
|
||||
|
||||
/** 二级页面标识 */
|
||||
const SUB_NONE: string = '';
|
||||
@ -39,19 +26,17 @@ export struct DevicePage {
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||
@State activeSub: string = SUB_NONE;
|
||||
@State bridgeConnected: boolean = false;
|
||||
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
|
||||
@State bridgeUrl: string = '';
|
||||
@State bridgeToken: string = '';
|
||||
@State deviceId: string = '';
|
||||
@State authorized: boolean = false;
|
||||
@State devices: DeviceInfo[] = [];
|
||||
@State loadingDevices: boolean = false;
|
||||
@State lastError: string = '';
|
||||
@State toastMsg: string = '';
|
||||
@State toastIsError: boolean = false;
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
private autoConnectTried: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.deviceId = deviceBridge.getDeviceId();
|
||||
@ -70,25 +55,9 @@ export struct DevicePage {
|
||||
// Gateway URL derives from current connection
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
this.bridgeUrl = this.gatewayUrlOf(cur.url);
|
||||
this.bridgeUrl = deviceGatewayUrl(cur.url);
|
||||
this.bridgeToken = cur.apiKey;
|
||||
}
|
||||
installCmdRouter();
|
||||
try {
|
||||
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
|
||||
} catch (e) {
|
||||
// ignore context errors
|
||||
}
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||||
this.bridgeConnected = open;
|
||||
});
|
||||
if (open) {
|
||||
this.lastError = '';
|
||||
this.showToast('设备网关已连接', false);
|
||||
this.refreshDevices();
|
||||
}
|
||||
});
|
||||
this.refreshDevices();
|
||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||
registerNavStack(2, this.navStack, () => {
|
||||
@ -104,37 +73,6 @@ export struct DevicePage {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
|
||||
*
|
||||
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
|
||||
* 直接把 http:// 传进 connect() 会在 native 层报
|
||||
* "protocol failed" + "ParseUrl failed"(NETSTACK websocket_exec.cpp),
|
||||
* 表现为设备通道永远连不上。所以这里必须做协议替换。
|
||||
*/
|
||||
private gatewayUrlOf(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
let scheme: string = 'ws://';
|
||||
let rest: string = trimmed;
|
||||
if (trimmed.startsWith('https://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('https://'.length);
|
||||
} else if (trimmed.startsWith('http://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('http://'.length);
|
||||
} else if (trimmed.startsWith('wss://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('wss://'.length);
|
||||
} else if (trimmed.startsWith('ws://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('ws://'.length);
|
||||
}
|
||||
return scheme + rest + '/api/v1/device/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开二级页面。
|
||||
*
|
||||
@ -170,45 +108,9 @@ export struct DevicePage {
|
||||
// ignore persist failure
|
||||
}
|
||||
deviceBridge.updateAuthorized(on);
|
||||
if (!this.bridgeConnected) {
|
||||
this.connectBridge();
|
||||
}
|
||||
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
||||
}
|
||||
|
||||
// ===================== gateway connection =====================
|
||||
|
||||
private async connectBridge(): Promise<void> {
|
||||
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
|
||||
this.lastError = noConnectionMessage();
|
||||
return;
|
||||
}
|
||||
this.lastError = '';
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||
}
|
||||
try {
|
||||
connStore.saveDeviceId(this.deviceId);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
const name: string = 'HomeAgent OHOS';
|
||||
await deviceBridge.connect(
|
||||
this.bridgeUrl, this.bridgeToken, this.deviceId,
|
||||
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
|
||||
}
|
||||
|
||||
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
|
||||
private maybeAutoConnect(): void {
|
||||
if (this.autoConnectTried || this.bridgeConnected) {
|
||||
return;
|
||||
}
|
||||
this.autoConnectTried = true;
|
||||
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
|
||||
this.connectBridge();
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||||
this.toastIsError = isError;
|
||||
@ -287,9 +189,6 @@ export struct DevicePage {
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
handleNavOnScroll(state);
|
||||
})
|
||||
.onAppear(() => {
|
||||
this.maybeAutoConnect();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
@ -479,7 +378,7 @@ export struct DevicePage {
|
||||
}
|
||||
|
||||
private capsCount(): number {
|
||||
return LOCAL_CAPS.length;
|
||||
return LOCAL_DEVICE_CAPS.length;
|
||||
}
|
||||
|
||||
// ===================== 二级:本机设备 =====================
|
||||
@ -542,7 +441,7 @@ export struct DevicePage {
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LOCAL_CAPS, (cap: string) => {
|
||||
ForEach(LOCAL_DEVICE_CAPS, (cap: string) => {
|
||||
Text(cap)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
@ -567,39 +466,8 @@ export struct DevicePage {
|
||||
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
|
||||
}
|
||||
|
||||
if (this.lastError.length > 0) {
|
||||
Text(this.lastError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
.padding({ left: 4 })
|
||||
.transition(TransitionEffect.OPACITY
|
||||
.combine(TransitionEffect.translate({ y: -8 }))
|
||||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
|
||||
// 因为父容器的 .animation() 到不了子节点。
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
||||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
||||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||||
.border({
|
||||
width: this.bridgeConnected ? 1 : 0,
|
||||
color: this.palette().btnGhostBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
if (this.bridgeConnected) {
|
||||
deviceBridge.disconnect();
|
||||
} else {
|
||||
this.connectBridge();
|
||||
}
|
||||
})
|
||||
}
|
||||
Blank()
|
||||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||||
Button('刷新设备')
|
||||
@ -615,7 +483,7 @@ export struct DevicePage {
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。')
|
||||
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
|
||||
@ -11,7 +11,9 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR
|
||||
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { GradientBackground } from '../components/GradientBackground';
|
||||
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
|
||||
import { ScreensuePage } from '../components/ScreensuePage';
|
||||
import { registerScreensueHandler } from '../common/BridgeRouter';
|
||||
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { window, display } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
@ -110,14 +112,14 @@ struct Index {
|
||||
this.syncSystemBar();
|
||||
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
||||
this.resolveSafeArea();
|
||||
// 设备桥:注册命令路由与 screensue 悬浮层回调
|
||||
installCmdRouter();
|
||||
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
|
||||
registerScreensueHandler((payload: ScreensuePayload) => {
|
||||
this.showScreensue(payload);
|
||||
});
|
||||
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
|
||||
}
|
||||
|
||||
/** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭;0=常驻)。 */
|
||||
/** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏;0=常驻)。 */
|
||||
private showScreensue(payload: ScreensuePayload): void {
|
||||
this.screensueText = payload.content;
|
||||
this.screensueCountdown = payload.duration;
|
||||
@ -218,7 +220,7 @@ struct Index {
|
||||
private updateWideScreen(w: number): void {
|
||||
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
||||
if (wide !== this.isWide) {
|
||||
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
|
||||
AppStorage.set<boolean>('isWideScreen', wide);
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,6 +232,10 @@ struct Index {
|
||||
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
||||
*/
|
||||
onBackPress(): boolean {
|
||||
if (this.screensueVisible) {
|
||||
this.closeScreensue();
|
||||
return true;
|
||||
}
|
||||
return handleBackPress(this.currentTab, this.isWide);
|
||||
}
|
||||
|
||||
@ -339,51 +345,42 @@ struct Index {
|
||||
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
|
||||
// screensue 悬浮层:agent 推送给用户看的内容(置顶展示)
|
||||
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
|
||||
// 左侧一级页面与主导航保持可见、可操作。
|
||||
if (this.screensueVisible) {
|
||||
Column() {
|
||||
if (this.isWide) {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.palette().accent)
|
||||
Text('agent 推送')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ left: 8 })
|
||||
Blank()
|
||||
if (this.screensueCountdown > 0) {
|
||||
Text(this.screensueCountdown.toString() + 's')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
Text('关闭')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
|
||||
.onClick(() => {
|
||||
Column()
|
||||
.width(WIDE_NAV_BAR_WIDTH)
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
|
||||
ScreensuePage({
|
||||
pushedText: this.screensueText,
|
||||
countdown: this.screensueCountdown,
|
||||
onClose: () => {
|
||||
this.closeScreensue();
|
||||
})
|
||||
},
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
.padding({ top: this.topInset })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Scroll() {
|
||||
Text(this.screensueText)
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
}
|
||||
.constraintSize({ maxHeight: 320 })
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
.height('100%')
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
} else {
|
||||
ScreensuePage({
|
||||
pushedText: this.screensueText,
|
||||
countdown: this.screensueCountdown,
|
||||
onClose: () => {
|
||||
this.closeScreensue();
|
||||
},
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ top: this.topInset })
|
||||
}
|
||||
.width('86%')
|
||||
.padding(18)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
@ -13,6 +13,7 @@ import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../co
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { StatusSummaryCard, StatusDetailContent } from '../components/StatusCards';
|
||||
import { statusStore } from '../common/StatusStore';
|
||||
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
|
||||
/** One settings key card rendered in the editor list. */
|
||||
interface SettingEntry {
|
||||
@ -201,6 +202,7 @@ export struct SettingsPage {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.loadConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.showToast('已切换连接', false);
|
||||
});
|
||||
}
|
||||
@ -224,6 +226,7 @@ export struct SettingsPage {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.loadConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.showToast('连接已添加', false);
|
||||
});
|
||||
}
|
||||
@ -242,6 +245,7 @@ export struct SettingsPage {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.loadConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.showToast('连接已更新', false);
|
||||
});
|
||||
}
|
||||
@ -278,10 +282,13 @@ export struct SettingsPage {
|
||||
private deleteConnection(id: string): void {
|
||||
connStore.deleteConnection(id).then(() => {
|
||||
this.loadConnections();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
} else {
|
||||
apiClient.clearConnection();
|
||||
}
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.showToast('连接已删除', false);
|
||||
});
|
||||
}
|
||||
|
||||
@ -17,6 +17,14 @@
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.READ_PASTEBOARD",
|
||||
"reason": "$string:read_pasteboard_reason",
|
||||
"usedScene": {
|
||||
"abilities": ["EntryAbility"],
|
||||
"when": "inuse"
|
||||
}
|
||||
}
|
||||
],
|
||||
"abilities": [
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "HomeAgent"
|
||||
},
|
||||
{
|
||||
"name": "read_pasteboard_reason",
|
||||
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ type msgEntry struct {
|
||||
|
||||
type daemonHandler struct {
|
||||
// homed 连接
|
||||
homeMu sync.Mutex
|
||||
homeMu sync.Mutex
|
||||
homeConn net.Conn
|
||||
homeR *bufio.Reader
|
||||
homeCfg *Config
|
||||
@ -304,14 +304,14 @@ func startDaemonDeviceBridge(cfg *Config) {
|
||||
if dg == "" || dt == "" {
|
||||
return
|
||||
}
|
||||
// 设备桥重连循环:WS 断开时自动重连
|
||||
go runDeviceBridgeLoop(dg, dt)
|
||||
// 设备桥重连循环:WS 断开时自动重连,并保留配置中的本地授权状态。
|
||||
go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized)
|
||||
}
|
||||
|
||||
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
|
||||
func runDeviceBridgeLoop(gateway, token string) {
|
||||
func runDeviceBridgeLoop(gateway, token string, authorized bool) {
|
||||
for {
|
||||
bridge, err := connectDeviceBridge(gateway, token)
|
||||
bridge, err := connectDeviceBridge(gateway, token, authorized)
|
||||
if err != nil {
|
||||
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
@ -325,7 +325,7 @@ func runDeviceBridgeLoop(gateway, token string) {
|
||||
}
|
||||
|
||||
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
|
||||
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
||||
func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "local"
|
||||
@ -355,12 +355,17 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
||||
}
|
||||
|
||||
bridge := client.New(gw, token, deviceID, hostname, caps, info)
|
||||
bridge.SetAuthorized(authorized)
|
||||
|
||||
// 注册命令处理器
|
||||
// 注册命令处理器:cmd_type 是主信号,同时兼容旧版 homeagent-* 文本前缀。
|
||||
cr := client.NewCmdRouter()
|
||||
cr.Handle("homeagent-", handleHomeagentCmd)
|
||||
cr.HandleDefault(handleShellCmd)
|
||||
bridge.OnCmd(func(reqID, command string) {
|
||||
bridge.OnCmd(func(reqID, command, cmdType string) {
|
||||
if cmdType == "homeagent" {
|
||||
handleHomeagentCmd(reqID, command)
|
||||
return
|
||||
}
|
||||
cr.Dispatch(reqID, command)
|
||||
})
|
||||
|
||||
@ -371,8 +376,6 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
||||
// 设置全局变量供 sendBridgeResult 使用
|
||||
deviceBridge = bridge
|
||||
deviceBridgeID = deviceID
|
||||
auth := true // daemon 模式默认授权(配置已指定)
|
||||
bridge.SetAuthorized(auth)
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
@ -61,13 +61,22 @@ func startDeviceBridge(addr, token string) error {
|
||||
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
|
||||
cmdRouter = client.NewCmdRouter()
|
||||
|
||||
// 注册命令处理器
|
||||
// cmd_type 是主路由信号;保留 homeagent-* 文本前缀兼容旧服务端。
|
||||
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
|
||||
cmdRouter.HandleDefault(handleShellCmd)
|
||||
bridge.OnCmd(func(reqID, command string) {
|
||||
bridge.OnCmd(func(reqID, command, cmdType string) {
|
||||
if cmdType == "homeagent" {
|
||||
handleHomeagentCmd(reqID, command)
|
||||
return
|
||||
}
|
||||
cmdRouter.Dispatch(reqID, command)
|
||||
})
|
||||
|
||||
// agent 主动投递(output_send__device/<id>)→ 终端显示。
|
||||
// 设备侧参考实现:文本/结构化直接打出来;二进制负载走 OnData(TTS 音频等)。
|
||||
bridge.OnPush(func(reqID, typ, payload, meta string) {
|
||||
printlnC("cyan", fmt.Sprintf("[push:%s] %s", typ, payload))
|
||||
})
|
||||
if err := bridge.Start(); err != nil {
|
||||
return fmt.Errorf("device bridge: %w", err)
|
||||
}
|
||||
@ -767,4 +776,4 @@ func sanitizeID(s string) string {
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
}
|
||||
|
||||
@ -710,18 +710,25 @@ main() {
|
||||
echo "=== Done! Packages in: $DIST_DIR ==="
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) 2>/dev/null | sort)
|
||||
# 只列**本批**产物:dist/ 会跨多次构建累积,用 find 全目录会让清单/SHA256SUMS
|
||||
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
|
||||
# (v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
|
||||
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "homeagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
|
||||
for f in "${release_files[@]}"; do
|
||||
echo " $(du -h "$f" | cut -f1) $f"
|
||||
done
|
||||
# 全部包生成之后一次计算,避免边打边算漏掉后生成的产物。
|
||||
# 名字用**平铺名**(basename):下载页的附件名就是平铺的,
|
||||
# 清单里若写 ./deb/xxx.deb,用户下载后 `sha256sum -c` 会找不到文件。
|
||||
if [ ${#release_files[@]} -gt 0 ]; then
|
||||
(
|
||||
cd "$DIST_DIR"
|
||||
find . -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) \
|
||||
-print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
|
||||
# 哈希取**真实路径**,标签用**平铺名**:两者不能混(直接对 basename 求哈希会找不到文件)。
|
||||
for f in "${release_files[@]}"; do
|
||||
printf '%s ./%s\n' "$(sha256sum "$f" | awk '{print $1}')" "$(basename "$f")"
|
||||
done | sort -k2 > SHA256SUMS
|
||||
)
|
||||
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS"
|
||||
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@ -130,6 +130,45 @@ main ──────────────── E ────────
|
||||
|
||||
---
|
||||
|
||||
### 7. 开发者文档的发布归属(以 rel 分支的形态为准)
|
||||
|
||||
**规则:面向使用者的开发者文档,先在对应的 `release/vX.Y.x` 上修正成「这一版的实际行为」,
|
||||
再 cherry-pick 合入 `main`。**(文档属 §二.3 所列的发布分支允许事项之一)
|
||||
|
||||
为什么不能直接改 main:
|
||||
|
||||
- `main` 的语义是**下一个未发布版本**(§二.1)。在那儿写的文档要么描述尚未发布的行为,
|
||||
要么与当前 rel 的实际行为**相反**,而文档的读者(包括模型自身)会把它当事实。
|
||||
- `assets/docs/**` 会**随发行包分发并在 WebUI 里被阅读**——它服务的是“这一版”,不是“下一版”。
|
||||
- 版本号、工具名、机制的有无都是**随版变动的**:同一个文件在两个分支上就应该是两种口径。
|
||||
|
||||
做法:
|
||||
|
||||
```bash
|
||||
git switch release/v1.2.x
|
||||
# 按这一版口径修改:版本号、当前工具名(hmapdev)、已移除机制不再写成现行
|
||||
# ... 编辑 assets/docs/**、README{,_EN}.md、docs/zh/** ...
|
||||
git commit -m "docs: 按 v1.2.x 口径修正 …"
|
||||
git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
```
|
||||
|
||||
`main` 上若需要描述“下一版才有的行为”,必须显式标注(如「(下一版)」或附版本号),
|
||||
不得让读者以为它已发布。
|
||||
|
||||
**反例(本仓真实踩过,均为“文档当成事实后反向误导”)**:
|
||||
|
||||
| 现象 | 后果 |
|
||||
|---|---|
|
||||
| 人格卡写死 `v0.9.0(C ABI v2)` | 内核接口/日志报 1.2.0,agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
|
||||
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
|
||||
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
|
||||
|
||||
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
|
||||
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
|
||||
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、当前分支对齐(2026-09-12 更新)
|
||||
|
||||
### 主仓(TrueAgent)
|
||||
|
||||
631
docs/zh/input-scheduler-design.md
Normal file
631
docs/zh/input-scheduler-design.md
Normal file
@ -0,0 +1,631 @@
|
||||
# 输入调度器设计(四级中断优先级 · 两类别 · 可抢占 · 现场保存)
|
||||
|
||||
> **模型更正(2026-09-13,据用户澄清重写 §2/§3/§4.1/§6.3/§9/§11/§12)**
|
||||
>
|
||||
> 本稿早期版本把「四级优先级」当成了**所有任务**的通用优先级,并按通道名
|
||||
> (qq→L2、cli→L3)由内核推断级别。那是错的。正确模型是**两类别 + 四级**:
|
||||
>
|
||||
> | | 中断输入(interrupt) | 排队输入(queued) |
|
||||
> |---|---|---|
|
||||
> | 注入 API | `InjectInterrupt*` | `InjectText*` / `InjectInputSync*` / 内核自循环 |
|
||||
> | 级别 | L1–L4 | **无级别** |
|
||||
> | 定位 | 需要及时处理 | 不需要及时处理 |
|
||||
> | 可被谁打断 | 仅**严格更高级**的中断 | **任何**中断 |
|
||||
>
|
||||
> 级别(“这项工作有多不能等”)由来源在 `InjectOptions.Priority` 里声明。
|
||||
> L1–L3 任何插件可声明;**L4 是“立即打断”能力**,只有**内核自身**(panic /
|
||||
> 内核事件 selfip,经 `raiseKernelInterrupt`)与**内核级插件**(编译期内置插件,
|
||||
> 如 WebUI 的终止按钮)能用。外部插件的 L4 会被夹到 L3。
|
||||
> 类别由**用哪个注入 API**决定,与通道名无关——QQ 走的是 `InjectInterruptTextOpts`,
|
||||
> 所以它是**低级别中断(L1)**,不是排队输入。
|
||||
|
||||
> 分支:`feature/input-semantics`
|
||||
> 状态:**设计稿 v1**(待确认项见 §12,未确认处按 §12 的「默认取值」推进)
|
||||
> 影响面:`internal/agent/core`、`internal/agent/io`、`internal/sdk`(**仅内部**)
|
||||
> 公开 SDK:**v1 不改**(`third_party/homeagent-sdk/sdk/` diff 必须保持为 0,理由见 §13)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景:现状与要解决的问题
|
||||
|
||||
现有两条输入语义(见审查结论),都建立在**串行 `eventLoop`** 之上:
|
||||
|
||||
| 语义 | 入口 | 路径 |
|
||||
|---|---|---|
|
||||
| 排队 | `InjectText` / `InjectInput*` | `io.inputCh` → `eventLoop` → `processInput` → `process()` |
|
||||
| 中断 | `InjectInterruptText` / `InjectInterrupt*` | `io.interruptCh` → `interceptLoop` → `cancelLLM` + `a.interceptCh` |
|
||||
|
||||
已确认的具体问题(均为源码事实):
|
||||
|
||||
1. **队头阻塞**:`eventLoop` 单 goroutine,`process()` 全程持 `a.mu`(`internal/agent/core/process.go:103-104`),一轮对话(含 N 轮工具)期间后续输入全部排队。
|
||||
2. **中断只在一种时刻成立**:`interceptLoop` 有三条降级回排队的路径——当前无 LLM 在跑、当前轮是 `_consolidation_`、`a.interceptCh` 满(`internal/agent/core/eventloop.go:74-116`)。它不是独立管线,而是"抢占 + 三次降级"。
|
||||
3. **回执误投风险**:`ResponseCh` 只由全局 `emitResponse` 写(`eventloop.go:475`),无任务归属。一旦引入抢占,中断的回执会写进被挂起任务的等待者。
|
||||
4. **断链点静默**:`processInput` 有多条提前 `return` 而不 `emitResponse` 的路径(`resolveInput` 失败 `:309`、10s 去重命中 `:317`、`_consolidation_` `:326`),同步调用方(`cli`、`clawhubadapter` 无超时)永久挂起。
|
||||
5. **背压策略分裂**:`inputCh` 满 → 阻塞发送方;`selfInputCh` 满 → 静默丢弃;`a.interceptCh` 满 → 降级回 `inputCh`。
|
||||
6. **假取消**:工具超时只是放弃等待,内层继续执行且副作用照做(`internal/agent/core/toolcall.go:33-45`)。
|
||||
7. **不可观测**:没有任何"当前在跑什么、谁被挂起、降级了多少次"的统一入口。
|
||||
|
||||
**目标**:把上述隐式行为收敛成一个**显式、可 dump、可单测、可断言**的调度器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 术语与模型
|
||||
|
||||
```
|
||||
Task = { id, class, level, origin, frame, state, enqueueAt, preemptCount, responseCh }
|
||||
class: queued | interrupt // **类别由注入 API 决定,与通道名无关**
|
||||
queued —— 无级别;用于“不需及时处理”的场景;可被**任何**中断打断
|
||||
interrupt —— 带级别 L1..L4;仅被**严格更高级**的中断打断(被打断则压入中断栈)
|
||||
level: 仅 interrupt 有意义(queued 恒无级别,effectiveLevel 视作 0)
|
||||
state: ready | running | suspended | done
|
||||
|
||||
TaskFrame = {
|
||||
msgs []Message // 本任务自己的 LLM 消息序列
|
||||
step Step // 下一个要执行的 step(安全点游标)
|
||||
turn int // 已完成的工具轮数
|
||||
toolIdx int // 当前工具批内的下标
|
||||
toolResults []ToolResultItem
|
||||
toolsUsed []string
|
||||
lastBatchReplyOnly bool
|
||||
stageCtx *sdk.StageContext
|
||||
budget TokenBudget
|
||||
outputChannel string // 该任务的输出通道
|
||||
responseCh chan<- *OutputEvent // 任务级回执信道(可为 nil)
|
||||
startedAt time.Time
|
||||
input string // 触发本任务的输入文本(COMMIT 时写记忆)
|
||||
noMemory bool
|
||||
originSource string
|
||||
}
|
||||
|
||||
Step(枚举,顺序执行,步与步之间是安全点):
|
||||
S_PREPARE 构建 msgs / 应用中断标记 / 合并 stage 上下文
|
||||
S_LLM LLM 流式调用(**可抢占**:cancel 即丢弃)
|
||||
S_POST StagePostAction
|
||||
S_LLM_JUDGE 无 tool_call → 去 S_BEFORE_OUTPUT;有 → 去 S_TOOL_BEGIN
|
||||
S_TOOL_BEGIN replyOnly 判定 + 取当前 tc
|
||||
S_TOOL_BEFORE StageBeforeToolcall
|
||||
S_TOOL_EXEC 工具执行(**临界区,不可抢占**)
|
||||
S_TOOL_AFTER StageAfterToolcall
|
||||
S_TOOL_NEXT 批内下一个 / 批结束 → S_LLM
|
||||
S_BEFORE_OUTPUT / S_AFTER_OUTPUT
|
||||
S_COMMIT context.Append + emitMemoryCandidate(**原子,不可抢占**)
|
||||
S_FINISH 写 responseCh、发事件
|
||||
```
|
||||
|
||||
四个容器(**不是**“三集合并成一个比较器”):
|
||||
|
||||
| 容器 | 内容 | 取出规则 |
|
||||
|---|---|---|
|
||||
| `interruptQueues[1..4]` | **中断队列**,每条队列一个级别 | 从 L4 到 L1 依次扫描;同级 FIFO |
|
||||
| `immediate` | 刚抢占成功的那一条中断(**至多一个**) | 最先取出——抢占必须**立即生效** |
|
||||
| `queue` | **排队输入**形成的新任务 | 纯 FIFO(无级别可比) |
|
||||
| `suspendStack`(**中断栈**) | 被打断、保存了现场的任务 | **LIFO,只比栈顶**;栈内不做重排 |
|
||||
|
||||
> 用词(已更正):它**就是中断栈**。用户明确存在「中断被中断」的场景,被打断的现场必须压栈;
|
||||
> 因此恢复纪律是**严格 LIFO(只比栈顶)**,栈内不做优先级重排。
|
||||
> 早期稿把它写成“不是栈、按优先级取”是错的。
|
||||
>
|
||||
> 早期稿还让 `immediate` 与别的容器共用同一个比较器,于是出现“抢占成功后,
|
||||
> 抢占者与被挂起者同级 → 原任务被立刻选回 → 抢占空转”——为此打的
|
||||
> “同级 pending 优先”补丁已删除:抢占者根本不进队列。
|
||||
|
||||
---
|
||||
|
||||
## 3. 优先级(只属于中断)
|
||||
|
||||
### 3.1 两类别 + 四级
|
||||
|
||||
**类别(`TaskClass`)由注入 API 决定,与通道名无关**:
|
||||
|
||||
| 类别 | 注入入口 | 级别 | 可被谁打断 |
|
||||
|---|---|---|---|
|
||||
| `queued` 排队 | `InjectText*` / `InjectInputSync*` / `InjectInputMedia*` / 内核自循环(`selfInputCh`) | **无** | **任何**中断(L1 也能) |
|
||||
| `interrupt` 中断 | `InjectInterrupt*` | L1–L4 | 仅**严格更高级**的中断 |
|
||||
|
||||
**级别(`Level`)语义是“这项工作有多不能等”**:
|
||||
|
||||
| Level | 名称 | 语义 | 典型来源 |
|
||||
|---|---|---|---|
|
||||
| `L4` | CRITICAL | 内核紧急 | **内核独占**:panic 中断、内核事件中断(selfip) |
|
||||
| `L3` | INTERACTIVE | 需及时处理 | 时钟/定时器到达、终端输出、交互输入 |
|
||||
| `L2` | MESSAGE | 一般提醒 | 插件希望尽快看到、但不紧急的提示 |
|
||||
| `L1` | BACKGROUND | 完全可等 | 异步消息(QQ/微信)、批量通知 |
|
||||
|
||||
- **`queued` 没有级别**:它本就是“不需及时处理”的那一类,
|
||||
所以“可被任何中断打断”不是漏洞而是定义(`effectiveLevel(queued) == 0`)。
|
||||
- **默认级 = `L1`**:未声明一律最低级(“显式才是特权”,新插件不会默认拿到抢占权)。
|
||||
|
||||
### 3.2 级别从哪来
|
||||
|
||||
| 来源 | 可达级别 | 入口 |
|
||||
|---|---|---|
|
||||
| 普通插件(外部,独立进程/动态库) | L1–L3 | `InjectOptions.Priority`(空/非法 → L1;L4 被夹到 L3) |
|
||||
| **内核级插件**(编译期内置,`init()` 自注册) | L1–**L4** | 同上;L4 用于实现**中断能力**,例如 WebUI 的终止按钮 |
|
||||
| 内核自身 | L4 | `(*Agent).raiseKernelInterrupt`(panic / selfip) |
|
||||
|
||||
- ❌ **不是运维可调项**。不引入 `core.agent.priority.<channel>` 这类配置键,
|
||||
也不把 `PriorityLookup` 做成可注入的策略表。
|
||||
- ✅ 插件**可以声明**自己中断的级别(这不是“把内核内部属性外化”,
|
||||
而是调用方声明它自己那件事有多不能等)。
|
||||
- ✅ **L4 给“立即打断”能力**:内核自身(panic / selfip)与**内核级插件**
|
||||
(编译期内置插件,如 WebUI 终止按钮)可声明。为什么必须给内置插件:
|
||||
用户按下终止按钮时,内核需要一条能立刻打断当前任务的中断;这条能力不能给
|
||||
外部插件,否则任何第三方插件都能随时打断用户的一切工作。
|
||||
- **判据是“这个插件是不是编译期内置”,不是它自报的名字**:
|
||||
- 第一道闸在 **proc 桥**(外部进程的唯一入口):走它的一律把 L4 夹到 L3。
|
||||
在这里夹而不是只按 `source` 判,是因为 `source` 是插件自报字段、可以冒名。
|
||||
- 第二道闸在 **core**:`isKernelLevelSource(source)` 查
|
||||
`pluginReg.IsBuiltinPlugin`,只有内置工厂才承认 L4(纵深防御)。
|
||||
- `source` 的约定是 `插件名` 或 `插件名/实例`(如 `webui/<deviceID>`),
|
||||
判据取第一段——否则带设备身份的 WebUI 来源会被误判成外部插件。
|
||||
|
||||
### 3.3 抢占判据
|
||||
|
||||
```go
|
||||
effectiveLevel(queued) == 0
|
||||
canPreempt(incoming, running) = incoming.Class == TaskInterrupt
|
||||
&& effectiveLevel(incoming) > effectiveLevel(running)
|
||||
```
|
||||
|
||||
因为 `queued` 的有效级恒为 0,这一个比较同时覆盖两条规则:
|
||||
|
||||
```
|
||||
running 是排队任务 → 任何中断(≥L1)都抢占
|
||||
running 是中断 Li → 只有 Lj > Li 的中断抢占(严格大于)
|
||||
incoming 是排队输入 → 永不抢占
|
||||
```
|
||||
|
||||
**严格大于才抢占**;相等一律入队——这条保证确定性,也是“较低无法打断较高”的字面实现。
|
||||
|
||||
## 4. 调度规则
|
||||
|
||||
### 4.1 选择函数(四容器 · 固定次序)
|
||||
|
||||
任务结束、或运行任务到达安全点且存在待处理抢占请求时,执行:
|
||||
|
||||
```
|
||||
1. immediate 非空 → 取它(刚抢占成功的中断,抢占必须立即生效)
|
||||
2. 中断队列非空 → 取 L4→L1 中最高级非空队列的队头(同级 FIFO)
|
||||
3. 中断栈非空(与 2 比高) → 栈顶有效级 ≥ 队头级别 ? 弹栈顶 : 取队头
|
||||
4. queue 非空 → 取队头(纯 FIFO)
|
||||
5. 都没有 → 空闲(阻塞等新输入 / 新中断)
|
||||
```
|
||||
|
||||
- **中断栈只把栈顶**放进比较(严格 LIFO)——栈内更老的任务即使因饥饿防护
|
||||
提升了有效级,也不得越过栈顶;“后被打断的先恢复”才是栈语义。
|
||||
- 第 3 步就是用户给的规则:“先判断中断队列是否为空,同时判断中断栈中任务的
|
||||
优先级,哪个优先级高取出哪个”。栈顶是 `queued`(有效级 0)时,任何中断都赢。
|
||||
- 第 1 步的存在,使“抢占者与被抢占者同级”这个比较**根本不会发生**:
|
||||
抢占者不经队列。这是删除早期“同级 pending 优先”补丁后的正确形态。
|
||||
- 排队任务只在中断与挂起现场都处理完后才执行——这正对应“排队输入用于
|
||||
不需要及时处理的场景”。
|
||||
|
||||
### 4.2 安全点(可切换点)
|
||||
|
||||
**只有 step 与 step 之间是安全点。** 明确:
|
||||
|
||||
- ✅ `S_LLM` 之后、`S_TOOL_BEFORE` 之后、`S_TOOL_EXEC` **之后**、`S_TOOL_AFTER` 之后……
|
||||
- ❌ `S_TOOL_EXEC` **执行中不是安全点**:工具副作用不可回滚,无法"保存现场"。
|
||||
|
||||
### 4.3 临界区
|
||||
|
||||
```
|
||||
CriticalSection:step 标记 nonPreemptible = true,或任务进入声明区间
|
||||
```
|
||||
|
||||
- 实现方式:**调度器在临界区期间不求值抢占**(协作式单线程下即"不 yield"),不使用 `sync.Mutex`。
|
||||
- 资源互斥不用锁,而是**调度器持有的资源表**(若某资源被 running 占用,则不会选出同样占用它的任务)——纯数据判定,天然无优先级反转。
|
||||
- **v1 临界区清单**(显式列出,避免"隐式临界区"):
|
||||
|
||||
| 临界区 | 理由 |
|
||||
|---|---|
|
||||
| `S_TOOL_EXEC`(单次工具执行全程) | 副作用不可回滚;插件 RPC 不可取消 |
|
||||
| `S_COMMIT` | 上下文/记忆写入必须原子 |
|
||||
| 需 ONNX 嵌入的 `S_PREPARE` 片段 | ONNX `Run` 不可取消 |
|
||||
| 媒体 CAS 落盘 | 同上 |
|
||||
| 显式声明的 `_consolidation_` 类任务 | 记忆一致性 |
|
||||
|
||||
- 临界区期间到达的抢占请求**不丢失**:按级别进入中断队列,在临界区结束后的第一个安全点重新求值。
|
||||
|
||||
### 4.4 背压(v1 统一为一种)
|
||||
|
||||
- `readyQueue` 有界(默认 256,可配)。
|
||||
- 满时:**阻塞发送方**(与现状 `inputCh` 一致,避免静默丢用户输入),但必须**计数并打日志**。
|
||||
- 中断队列合计有界(默认同 `maxQueue`);满时**丢弃最低级别里最老的一条并计数**(中断是提示性输入,宁可丢旧保新)。
|
||||
- 中断栈帧数上界是**结构推论 = 4**(见 §6.3),不是配置项。
|
||||
|
||||
---
|
||||
|
||||
## 5. 中断语义
|
||||
|
||||
### 5.1 中断产生线程的职责(钉死)
|
||||
|
||||
`interruptLoop` 只做三件事,**绝不触碰任何 TaskFrame**:
|
||||
|
||||
```
|
||||
① 从 io.interruptCh 收中断 → 定级(读 payload["priority"],插件声明 L1..L3)
|
||||
② 决策(scheduler.registerInterrupt 内):
|
||||
canPreempt(incoming, running) 且 running 不在临界区
|
||||
→ 置让位信号 + 把 incoming 放进 immediate 槽,并返回 true(调用方据此
|
||||
取消当前可取消的 step,即 LLM 流式)
|
||||
否则
|
||||
→ 按级别进入对应的中断队列
|
||||
③ 唤醒调度器(scheduler.wake,cap 1)
|
||||
```
|
||||
|
||||
共享面仅三处:让位信号(`preemptArmed`/`preemptLevel`)、中断队列、`critical` 原子标志。
|
||||
**帧的保存与恢复只能由调度器做。**
|
||||
|
||||
### 5.2 三种情形的统一
|
||||
|
||||
现状的三条降级路径在新模型里不再需要特殊分支:
|
||||
|
||||
| 情形 | 旧模型 | 新模型 |
|
||||
|---|---|---|---|
|
||||
| LLM 在跑,正常 | 真抢占(同轮 continue) | 真抢占:`S_LLM` 取消,任务 A **压入中断栈**,中断任务 B 从 `S_PREPARE` 启动 |
|
||||
| LLM 没在跑 | 降级为排队 | B 按其级别入中断队列(空闲时即被 `wake` 唤醒并选出) |
|
||||
| `_consolidation_` 中 | 降级为排队 | `_consolidation_` 是后台**临界区**(且它是排队任务)→ B 入中断队列,临界区结束后求值 |
|
||||
| `a.interceptCh` 满 | 降级为排队 | 不存在该队列;中断队列有界,满则丢最低级别里最老的一条 |
|
||||
|
||||
### 5.3 中断任务与被打断任务的关系(**已定:D1 = 方案 B**)
|
||||
|
||||
> 用户明确:
|
||||
> *“中断打断时,上个任务到达以来的所有上下文现场被保护(含 toolcall),
|
||||
> 然后中断在**上个任务前的那个完整状态**上开始运行。中断运行结束,再把被挂起的
|
||||
> 任务与其上下文现场**加载回中断任务之上**,并继续运行。”*
|
||||
|
||||
因此语义是:
|
||||
|
||||
1. **被挂起任务的现场 = 它自到达以来累积的全部上下文(含 toolcall 结果)**,
|
||||
原样保存在 `TaskFrame` 里。
|
||||
2. **中断任务从「上一个任务之前的完整状态」开始运行**——它**看不到**被打断
|
||||
任务的任何部分进展。等价于:中断任务就是一个普通新任务,正常走 `S_PREPARE`
|
||||
(重建 system prompt + timeline + 自己的输入)。
|
||||
3. **中断结束后,把被挂起任务与其现场加载回「中断任务之上」再继续**:
|
||||
中断已提交的那段上下文留在**下面**(成为重建前缀的一部分),本任务自己的
|
||||
现场接回**其上**。
|
||||
|
||||
实现对应(`internal/agent/core/task.go`):
|
||||
|
||||
- `TaskFrame.PrefixLen` 记录 prepare 段构建的**基础前缀**长度
|
||||
(system + timeline + 用户输入);其后的 Stage 上下文与工具轮产物都是“自己的现场”。
|
||||
- `rebaseFramePrefix(f)`:恢复时重建基础前缀(因中断结束已把它的输入/输出提交进
|
||||
`a.context`,重建出的 timeline 已含中断效果),再把 `f.Msgs[PrefixLen:]` 原样接回;
|
||||
并补回 prepare 段的両处尾部改写(`IsInterrupt` 的 `[中断消息]` 标记、输入多模态块)。
|
||||
- 调用点:`resumeTask` 在 `runTaskSteps` **之前**调用它。
|
||||
|
||||
> 代价(已知且接受):中断看不到“进行到哪一步”,所以“别搜了改成 X”这类指令
|
||||
> 只能靠它自己重新理解;换来的是中断起点总是一个**一致的完整状态**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 保存现场与恢复
|
||||
|
||||
### 6.1 保存
|
||||
|
||||
在安全点被抢占时:
|
||||
|
||||
```
|
||||
suspendStack.push(Task{frame: running.frame, state: suspended,
|
||||
step: running.frame.step, enqueueAt: running.enqueueAt})
|
||||
running.state = done_for_now
|
||||
```
|
||||
|
||||
- **只保存数据帧**,不保存 goroutine 栈(这正是"单调度 + 隐式状态机"优于"park goroutine"的地方)。
|
||||
- `S_LLM` 被抢占时:**不完整的 LLM 请求直接丢弃**(LLM 调用幂等、无持久副作用);恢复时从 `S_LLM` **重发**,`msgs` 与抢占前一致(即"请求前"的状态)。
|
||||
- 已提交的副作用(已执行的工具、已 append 的 context)**不回滚**——帧里记录的 `toolResults` 会保留,恢复后继续。
|
||||
|
||||
### 6.2 恢复
|
||||
|
||||
从**中断栈栈顶**取出后:
|
||||
|
||||
1. **重建基础前缀**(`rebaseFramePrefix`)—— 此时中断任务已结束并提交,
|
||||
重建出的 timeline 包含中断的输入/输出,即“现场加载回中断任务之上”;
|
||||
2. 把本任务自己的尾部(Stage 上下文 + 工具轮产物 + 占位)原样接回;
|
||||
3. 从 `frame.Step` 继续执行。
|
||||
|
||||
被丢弃的只有那次**不完整的 LLM 请求**(幂等),已执行的工具与已累积的
|
||||
`toolResults` 全部保留。
|
||||
|
||||
### 6.3 嵌套
|
||||
|
||||
- 允许中断任务自身被更高级中断抢占(嵌套)。
|
||||
- **中断栈帧数上界 = 4,是结构推论而不是配置项**:
|
||||
链条 = `排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)`,
|
||||
被挂起 4 帧;L4 之上没有更高级别,链到此为止。
|
||||
(插件可达级别只到 L3,所以插件链最多挂起 3 帧 + 底层排队任务;
|
||||
第 4 帧只能由内核 L4 制造。)
|
||||
- 栈自底向上的**基础级**天然递增(能被抢占者必然级别更高),因此栈顶通常就是最高级任务。
|
||||
- 超限在正确模型下不可达:`susp` 处只做**防御性计数**(`Rejected++`),
|
||||
**不降级、不丢弃帧**——帧丢了会丢副作用记录。早期稿写的“超限转 pendingInterrupts”已删除。
|
||||
|
||||
## 7. 回执路由(任务级)
|
||||
|
||||
**必须改**:`ResponseCh` 从"全局 `emitResponse` 的对象"上升为 `TaskFrame.responseCh`。
|
||||
|
||||
```
|
||||
emitResponse(task, ...) // 写 task.frame.responseCh,而不是"当前全局通道"
|
||||
```
|
||||
|
||||
- 抢占场景下,中断任务 B 的 `S_FINISH` 只可能写 `B.responseCh`,绝不会写进被挂起的 `A.responseCh`。
|
||||
- **不变量**:**每个任务在 `S_FINISH` 必然产生且仅产生一个终态事件**(无论成功、失败、被跳过)。`processInput` 现有的三个提前 return(解析失败、去重、consolidation)在新模型里都必须转成"任务以 `skipped` 终态结束并回执"。
|
||||
- 这顺带修掉现有缺陷:`cli`(`internal/plugins/cli/plugin.go:242`)与 `clawhubadapter`(`internal/plugins/clawhubadapter/plugin.go:1045`)的同步注入在断链时会永久挂起。
|
||||
|
||||
---
|
||||
|
||||
## 8. 并发结构(两个 goroutine)
|
||||
|
||||
```
|
||||
schedulerLoop(唯一持有任务状态与帧)
|
||||
for {
|
||||
if 可切换 && preemptionRequest 有效 → 执行抢占(保存现场)
|
||||
if running == nil → pick from 三集合;无候选则等待 inbox
|
||||
runOneStep(running) // 可能是阻塞调用(见 §8.2)
|
||||
处理 step 结果 → 推进或结束任务
|
||||
}
|
||||
|
||||
interruptLoop(不持有任何帧)
|
||||
收 interruptCh → 定级 → 决策 → 置 preemptionRequest + cancel + wake scheduler
|
||||
```
|
||||
|
||||
### 8.1 不变量
|
||||
|
||||
| # | 不变量 |
|
||||
|---|---|
|
||||
| I1 | 任意时刻至多一个 `running` 任务("一个 running"约束的是**副作用**,不只是 CPU) |
|
||||
| I2 | 任务帧只由 `schedulerLoop` 读写;`interruptLoop` 只写 `preemptionRequest` / 读 `stepCancel` |
|
||||
| I3 | 任何跨挂起点的状态都是纯数据,不持有锁 |
|
||||
| I4 | 安全点只在 step 边界;工具执行中与 COMMIT 不是安全点 |
|
||||
| I5 | 每个任务恰好一次终态事件(含 `responseCh` 写入) |
|
||||
| I6 | 调度器本身永不退出(panic 只使当前任务失败) |
|
||||
|
||||
### 8.2 关于"调度器不被阻塞"(**待确认 D2**)
|
||||
|
||||
> **M3 拆分的理由**:真正的挂起要求帧跨越 `prepare → step… → finish` 全生命周期。
|
||||
> 若只把 `process()` 改成可挂起,`processInput` 会在挂起返回后继续执行
|
||||
> `context.Append` 与 `emitResponse`——造成重复提交。故 M3 分为 M3a(所有权重构,
|
||||
> 行为等价)与 M3b(抢占语义)两步。
|
||||
|
||||
v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 step 上会阻塞进插件 RPC / 原生调用。** 这是有意的取舍:
|
||||
|
||||
- 好处:与"两个 goroutine 就够"一致,实现简单,无临时 goroutine。
|
||||
- 代价:这些临界区期间**中断只能排队,不能抢占**。换言之,**中断的有效窗口 = `S_LLM`**(与今天的实际行为相同,但现在是显式声明而非隐式结果)。
|
||||
- 演进(v2):把 `S_TOOL_EXEC` 改成异步 step(临时 goroutine + 完成事件),并给插件协议加 `tool.cancel`。此路径在文档保留,不在 v1 实现。
|
||||
|
||||
### 8.3 panic 隔离与 panic 中断
|
||||
|
||||
- `runOneStep` 外包 `recover`:panic → 当前任务标记 `failed`,**调度器继续**。
|
||||
- panic 同时**产生一条内核 L4 中断**(`reportTaskPanic` → `raiseKernelInterrupt`):
|
||||
内核把自己发生了 panic 这件事作为最高级中断通知给调度器,让 agent 能知情/善后。
|
||||
- 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,不再产生新的 L4——
|
||||
否则同一个 panic 会自我放大成中断风暴。
|
||||
- 取代现有 `eventLoop`/`interceptLoop` 的 `recover → sleep 1s → go loop()` 无退避重启(`eventloop.go:19-22,38-42`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 失效模式与防御
|
||||
|
||||
| 失效 | 防御 |
|
||||
|---|---|
|
||||
| 饥饿(高优先级流反复抢占) | `preemptCount` 提升有效级:`effectiveLevel = min(4, baseLevel + min(preemptCount, 2))`;被抢占 +1。**只对中断生效**——排队任务无级别,按定义可被任何中断打断 |
|
||||
| 无界下潜 | 中断栈帧数上界 4(结构推论 = 中断级数);超限只做防御性计数,**不降级不丢帧** |
|
||||
| 中断请求堆积 | 中断队列合计有界,满则丢最低级别里最老的一条并计数 |
|
||||
| 就绪队列满 | 阻塞发送方 + 计数(不静默丢) |
|
||||
| 同一任务反复被打断 | `preemptCount` 达阈值后有效级提升;另设**抢占冷却**:刚被抢占的任务在 `cooldown` 内不再被同级/更低级抢占 |
|
||||
| 任务永不结束 | 每任务 `maxTurns`(主循环目前缺失,见审查 P0)+ 每步超时 |
|
||||
| 不可观测 | `Scheduler.Dump()` 原子快照 + 事件(切换原因、降级次数、丢弃次数) |
|
||||
|
||||
---
|
||||
|
||||
## 10. 非目标(v1 明确不做)
|
||||
|
||||
1. 工具级取消 / 可抢占工具(`tool.cancel`)。
|
||||
2. 多 agent 并行(仍是单 agent 单调度器)。
|
||||
3. 公开 SDK 接口变更。
|
||||
4. 微抢占(任意指令级)。
|
||||
5. 跨进程恢复(帧不落盘)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 测试点、测试方式与预期结果
|
||||
|
||||
测试基础设施(先于 M1 落地):
|
||||
|
||||
- **假时钟** `Clock` 接口(`Now()` / `AfterFunc`),生产用真实实现,测试注入可控时钟。
|
||||
- **假 Provider**:实现 `agentAPI.Provider`,返回脚本化的 `tool_calls` 序列(支持"第 N 次调用时挂起直到放行")。
|
||||
- **假工具**:测试内 `StageHost.RegisterTool` 注册,可控制每次执行耗时、是否返回错误、是否触发中断注入。
|
||||
- **同步栅栏**:测试通过 `scheduler.Inbox` 注入中断并用 `runtime.Gosched` + 显式 `waitFor(state)` 断言,不用 sleep 猜时序。
|
||||
- **快照断言**:`scheduler.Dump()` 返回 `{running, queue, interruptQueues[1..4], immediate, suspendStack, counters}`,测试对纯数据断言。
|
||||
|
||||
### 11.1 优先级与抢占
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| P1 | 更高中断抢占中断 | running=L2 在 `S_LLM`;注入 L3 中断 | L2 压入中断栈(step=S_LLM);L3 进 `immediate` 并变 running |
|
||||
| P2 | 相等级别不抢占 | running=L2 中断在 `S_LLM`;注入 L2 | 不抢占;请求入 L2 中断队列;running 不变 |
|
||||
| P3 | 更低级别不抢占 | running=L3 中断;注入 L2 | 同上,不抢占 |
|
||||
| P4 | 逐级抢占嵌套 | 排队任务 → L1 → L2 → L3 → L4,均在 `S_LLM` | 中断栈深度依次 1/2/3/4;每层 step 均为 S_LLM |
|
||||
| P5 | 抢占后在安全点才生效 | running=排队任务在 `S_TOOL_EXEC`;注入 L4 | 抢占**不立即生效**;工具返回后才保存/切换;`deferredPreemptions==1` |
|
||||
| P6 | 临界区不可抢占 | running 声明临界区;注入 L4 | 同上;L4 请求留在中断队列,临界区结束立即被选中 |
|
||||
| **P7** | **排队任务被任何中断打断** | running=排队任务;注入 **L1** 中断 | L1 也抢占成功(排队任务有效级 0) |
|
||||
| **P8** | **排队输入永不抢占** | running=任意任务;注入排队输入 | 不抢占,入排队队列 |
|
||||
| **P9** | **外部插件不能声明 L4** | 外部来源声明 `Priority="L4"` | 被夹到 L3(proc 桥 + core 双重) |
|
||||
| **P11** | **内核级插件可用 L4** | 内置插件(如 webui)声明 `L4` | 得到 L4 并立即打断当前任务(终止按钮) |
|
||||
| **P10** | **panic 产生 L4 中断** | 任务 panic | 产生一条带 `kernel=true` 的 L4 中断;L4 自身 panic 不再递归 |
|
||||
|
||||
### 11.2 保存现场与恢复
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| R1 | 在 `S_LLM` 抢占后恢复 | 构造 A 在 `S_LLM` 被 B 抢占;B 结束 | A 恢复后**重新发起** LLM 请求;`msgs` 与 A 被抢占前**逐字节相同**;不重复执行已完成的工具 |
|
||||
| R2 | 在 `S_TOOL_BEGIN` 抢占后恢复 | A 完成 1 个工具批后于 `S_TOOL_BEGIN` 被抢占 | A 恢复后继续**下一批**工具;`toolResults` 长度不变 |
|
||||
| R3 | 恢复结果与不中断一致 | 同一脚本跑两次:一次中途注入中断,一次不注入 | 两次最终 `context` 事件序列**除"中断任务自身的事件"外一致**;A 的 `toolsUsed` 顺序相同 |
|
||||
| R4 | 嵌套恢复顺序 | L4→L3→L2 依次抢占后依次结束 | 按有效级/到达序恢复;每个任务的 `frame.step` 与其被挂起时一致 |
|
||||
| R5 | 不完整的 LLM 请求被丢弃 | 假 Provider 在流式途中触发中断 | 该次请求被 cancel;**不产生任何 `msgs` 追加、不产生 tool_call**;恢复后重发次数 = 1 |
|
||||
|
||||
### 11.3 回执路由
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| X1 | 任务级回执不误投 | A 为同步任务并已挂起;B 为同步中断 | B 的回执只到 `B.responseCh`;`A.responseCh` 在 A 恢复并结束后才收到自己的回执 |
|
||||
| X2 | 断链路径必有终态 | 分别构造:解析失败、10s 去重命中、`_consolidation_` | 三种都产生 `skipped` 终态事件并回执;同步调用方**不挂起** |
|
||||
| X3 | 每任务恰一次终态 | 统计 `S_FINISH` 次数 vs 任务数 | 相等(I5),无重复写入 |
|
||||
| X4 | 无超时同步注入不再永久挂起 | `cli` 路径(无超时)注入一条会被去重的输入 | 返回 `skipped` 回执而非永久阻塞 |
|
||||
|
||||
### 11.4 队列与选择
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| Q1 | 中断队列按级别扫 | 四条中断队列各放一个,入队顺序与级别相反 | 取出顺序 L4→L3→L2→L1;中断耗尽后才是排队任务(FIFO) |
|
||||
| Q2 | 挂起现场优先于新排队工作 | A 被抢占挂起 + B 为新排队输入 | A(栈顶)先被选中 |
|
||||
| Q3 | 栈顶 vs 中断队头 | 栈顶 L3 + 队头 L2 / 栈顶 L3 + 队头 L4 / 栈顶为排队任务 + 队头 L1 | 分别取 栈顶 / 队头 / 队头 |
|
||||
| Q4 | 就绪队列背压 | readyQueue 满后注入排队输入 | 发送方阻塞 + 计数 +1;不静默丢弃 |
|
||||
| Q5 | 中断队列溢出 | 中断队列合计满后注入更多 | 丢**最低级别里最老**的一条 + 计数;其余保持 |
|
||||
| **Q6** | **immediate 最优先** | `immediate` 非空且中断队列里有更高级别 | 取 `immediate`(抢占必须立即生效) |
|
||||
|
||||
### 11.5 深度、饥饿与并发
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| D1T | 下潜深度上界(结构推论) | 挂起 3 帧后继续注入;再挂起到 4 帧 | 3 帧时 `canSuspend()==true`;4 帧(全链:排队+L1+L2+L3,L4 运行中)时为 `false` |
|
||||
| G1 | 饥饿防护(抢占提升) | 对同一 **L1 中断**连续抢占 5 次(同级/高级交替) | `effectiveLevel` 提升至 `min(4, 1+2)=3`;第 3 次后不再被 L1/L2 抢占 |
|
||||
| G2 | 冷却生效 | 同一中断刚被抢占后立刻再注入同级中断 | 冷却期内不抢占,请求入中断队列 |
|
||||
| **G3** | **提升也必须只在中断间生效** | 排队任务被连续抢占 | 排队任务有效级恒 0(不被提升;它按定义可被任何中断打断) |
|
||||
| K1 | panic 隔离 | 假工具 panic | 只有该任务变 `failed`;调度器存活;后续任务正常执行 |
|
||||
| K2 | 竞态检查 | 全部调度用例加 `-race` | 无数据竞争报告 |
|
||||
| O1 | 快照一致性 | 在任意 step 边界调 `Dump()` | 返回的 `running/ready/pending/suspend` 三集合互不重叠且总数守恒 |
|
||||
| O2 | 切换可观测 | 每次抢占/恢复 | 产生一条事件(任务 id、原因、from→to、level) |
|
||||
|
||||
### 11.6 端到端
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期结果 |
|
||||
|---|---|---|---|
|
||||
| E1 | 真实 provider + 假长工具 | 启动内核,用一个会阻塞 5s 的假工具跑 L1 任务,途中经 `interceptCh` 注入 L4 中断 | 中断**在工具执行期间不被处理**;工具返回后立即抢占;中断任务先完成;原任务恢复并完成 |
|
||||
| E2 | LLM 流式中断 | 假 Provider 慢速流式返回 | 中断后当前流被 cancel,任务挂起,中断任务完成,原任务恢复并重新请求 |
|
||||
| E3 | 现有 e2e 回归 | 跑 `internal/plugins/integration_test.go`、`real_plugin_smoke_test.go` | 行为不变(除文档化的语义变化) |
|
||||
| **E4** | **优先级压力(用户指定形状)** | 固定内容假 provider(**记延迟,且被取消时立刻返回**),100 条排队输入 + 100 条中断(L1/L2/L3/L4 各 25)混合打入;每条中断都等到“该被它打断的受害者正在跑”时才注入 | 200 个任务全部到达终态;`Rejected=0`;各级登记数 = 25;**各级抢占数都 > 0**;排空后 `Suspended == Resumed`;每次“取消流式段”都换来一次挂起 |
|
||||
| **E5** | **嵌套到结构上限并 LIFO 展开** | 排队任务运行中依次注入 L1→L2→L3→L4(每级都等上一级在跑) | 栈深峰值恰好 **4**(= 结构上限,`canSuspend()==false`);恢复顺序严格 LIFO `[L3, L2, L1, 排队]`;`Suspended==Resumed==4` |
|
||||
|
||||
> E4/E5 的 provider 必须**感知 ctx 取消**:否则抢占只能等任务自然结束,
|
||||
> 测到的全是"步骤之间让位",流式段的取消路径(真正的现场保存/恢复)压不到。
|
||||
> 实测:不感知取消时 `LLM完成 == 任务数`、挂起接近 0;感知后取消次数与挂起次数一一对应。
|
||||
|
||||
---
|
||||
|
||||
## 12. 待确认决策(含默认取值)
|
||||
|
||||
> 未获异议时按"默认取值"实现;每项单独一个 commit,便于回退。
|
||||
|
||||
| 编号 | 问题 | 默认取值 |
|
||||
|---|---|---|
|
||||
| **D1** | 中断任务的上下文 | **方案 B(已定)**:中断从上一个任务之前的完整状态开始;恢复时把被挂起任务的现场加载回中断之上 |
|
||||
| **D2** | 阻塞 step 处置:v1 全部声明为临界区(调度器可被阻塞)还是引入异步 step | **v1 = 临界区**;异步 step 留到 v2 |
|
||||
| **D3** | 中断队列与排队队列是否合一 | **完全分离**:中断按级别分四条队列(L4→L1 扫描),排队队列纯 FIFO,两者不共用比较器 |
|
||||
| **D7** | 任务类别怎么定 | **由注入 API 决定**(`InjectInterrupt*` = 中断;`InjectText*`/`InjectInputSync*`/自循环 = 排队),**不按通道名推断** |
|
||||
| **D8** | L4 归谁 | **内核独占**。唯一入口 `(*Agent).raiseKernelInterrupt`(panic / selfip);`clampPluginLevel` 把插件声明夹到 L3 |
|
||||
| **D9** | L1–L3 归谁 | **插件在 `InjectOptions.Priority` 里声明**(纯追加字段);空/非法降级到 L1 |
|
||||
| **D10** | 抢占者进入队列还是立即运行 | **立即运行**(`immediate` 槽)。这消除“抢占者与被挂起者同级”的比较,删除了早期的“同级 pending 优先”补丁 |
|
||||
| **D11** | 中断栈帧数上界 | **结构推论 = 4**(排队 L0 + I1 + I2 + I3 挂起,I4 运行中),不是配置项;超限只计防御性计数 |
|
||||
| **D4** | readyQueue 满时:阻塞发送方 or 返回错误 | **阻塞发送方 + 计数**(与现状一致,避免丢用户输入) |
|
||||
| **D5** | 饥饿防护:抢占计数提升 or 时间老化 | **抢占计数提升**(确定性、易测);时间老化留待需要时 |
|
||||
| **D6** | 主循环 `max_tool_turns` 是否在本特性一并落地 | **是**(审查 P0,且调度器需要"任务可终止"这一前提) |
|
||||
|
||||
---
|
||||
|
||||
## 13. 与发布纪律的关系
|
||||
|
||||
- 本特性在 `feature/input-semantics` 上开发,完成后合回 `main`,**不碰 `release/v1.2.x`**。
|
||||
- **公开 SDK 在本特性上有意新增**(feature 分支不受 rel 分支的接口冻结约束):
|
||||
`sdk.InjectOptions.Priority` 与 `sdk.PriorityL1/L2/L3`。这是为了让插件能声明
|
||||
自己中断的级别(§3.2)。
|
||||
- **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值
|
||||
等价于旧行为(L1)。
|
||||
- 合回 `main` 前需完成的发布动作:
|
||||
1. 同步更新 `docs/zh/plugin-interface-matrix.md`;
|
||||
2. 与 SDK 仓协同升 SDK 中版本;
|
||||
3. 遵守“只增不减、签名不改”边界。
|
||||
- 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`)
|
||||
同步追加 `priority`,与公开 SDK 字段一一对应。
|
||||
|
||||
## 14. 实现里程碑(逐个实现,每个 = 一个可独立验收的提交)
|
||||
|
||||
| 里程碑 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| **M0** | 测试基础设施:`Clock` 接口、假 Provider、假工具、`waitFor`、`Dump()` 骨架 | 新测试可运行;`go vet` 干净 |
|
||||
| **M1** | **纯重构**:把 `process()` 拆成显式 step 状态机 + `TaskFrame`;仍由现有 `eventLoop` 驱动,无优先级/无抢占 | R3、X3 通过;既有全部 agent 测试通过(行为等价) |
|
||||
| **M2** | 调度器骨架:单 `schedulerLoop` + `readyQueue`,取代 `eventLoop` 的输入处理;无优先级(全部 L1,纯 FIFO) | Q1/Q4 通过;integration 测试通过 |
|
||||
| **M3a** | **前置重构(本次拆分引入)**:把一轮对话的所有权从 `processInput` 移到调度器——帧覆盖 `prepare → step… → finish`;同时移除 `process()` 整轮持有的 `a.mu`(挂起不能持锁) | 既有全部 agent 测试 + 既有 e2e 通过(行为等价);`-race` 干净 |
|
||||
| **M3b** | `interruptLoop` 重写 + 四级优先级 + 严格大于抢占 + 中断栈 LIFO;只支持 `S_LLM` 抢占 | P1–P4、R1、R5、K1–K2 通过;嵌套 LIFO 判据通过 |
|
||||
| **M4** | 临界区 + `S_TOOL_EXEC` 声明 + 中断队列 + 深度上界(**后经模型更正重做,见下**) | P5–P6、D1T、Q3、Q5 通过 |
|
||||
| **M5** | 饥饿防护(抢占计数提升 + 冷却) | G1–G2 通过 |
|
||||
| **M6** | 任务级 `responseCh` + 断链点统一为终态事件 | X1–X4 通过;`cli`/`clawhub` 不再挂起 |
|
||||
| **M7** | 可观测性(`Dump()`/事件/状态页)+ 既有回归 | O1–O2、E1–E3 通过;`go test -race ./internal/agent/... ./internal/plugin/...` 全绿 |
|
||||
|
||||
每步收尾命令:
|
||||
|
||||
```bash
|
||||
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath
|
||||
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
|
||||
go build ./... && go vet ./...
|
||||
go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk/...
|
||||
```
|
||||
|
||||
### 实现状态(2026-09-13 完成)
|
||||
|
||||
| 里程碑 | 提交 | 验收结果 |
|
||||
|---|---|---|
|
||||
| M1 | `9a58878` | ✅ agent 全量 + `-race`;新增 `task_test.go` 4 项 |
|
||||
| M2 | `7082a50` | ✅ 新增 `scheduler_test.go` 6 组(含 O1/K1) |
|
||||
| M3a+M3b | `c69a1f1` | ✅ 新增 `task_lifecycle_test.go` 5 项、`scheduler_preempt_test.go` 5 项 |
|
||||
| M4 | `7565248` | ✅ 新增 `scheduler_critical_test.go` 3 项 |
|
||||
| M5 | `a971fc8` | ✅ 新增 `scheduler_starvation_test.go` 4 项 |
|
||||
| M6 | `4e4e0ad` | ✅ 新增 `task_terminal_test.go` 3 项 |
|
||||
| M7 | `f11de37` | ✅ 新增 `scheduler_e2e_test.go` 3 项(压力/可观测/端到端) |
|
||||
|
||||
#### 模型更正后的重构(2026-09-13,同一特性分支)
|
||||
|
||||
用户逐条澄清后重做调度核心(**行为有意的语义变化**,非等价重构):
|
||||
|
||||
| 项 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| 类别化 | `TaskClass{queued,interrupt}`;类别由注入 API 决定;`newInputTask`/`newSelfTask` 为 queued,`newInterruptTask` 为 interrupt | `scheduler_kernel_test.go` P7/P8 |
|
||||
| 级别归位 | `Level` 语义改为“中断级别”;`taskLevel()`(按通道名推断)删除,改为 `interruptLevel(evt, privileged)` 读 `payload["priority"]` | P9、P11、Q1 |
|
||||
| L4 内核独占 | `raiseKernelInterrupt`(panic/selfip);`requestKernelPreempt` 不夹取;panic 报告为 L4 且带递归保护 | P10、`TestKernel_PanicRaisesL4Interrupt` |
|
||||
| 选择结构 | `immediate` + 四条中断队列 + 排队 FIFO + 中断栈;删除统一比较器 `pickTaskIndex`/`taskBefore` 与“同级 pending 优先”补丁 | Q1–Q3、Q6 |
|
||||
| 栈上界 | `maxSuspendDepth`(配置语义)→ `maxInterruptFrames = int(LevelCritical)`(结构推论);删除“超限转 pending”降级 | D1T |
|
||||
| 公开 SDK | `InjectOptions.Priority` + `PriorityL1..L4`;io/proc 桥/插件模板同步透传;`example/qq` 声明 L1、`timer` 声明 L3、`webui` 终止按钮声明 L4 | `go test ./...` 全绿 |
|
||||
| 分级可观测 | `SchedulerStats.InterruptsByLevel[1..4]` / `PreemptsByLevel[1..4]`(按级别分桶,见 §11.6 E4) | 压力测试按级别断言 |
|
||||
| 计数修正 | `Resumed` 原本在 `nextRef` 与 `resumeTask` **各计一次**(双计),使"排空后 Suspended==Resumed"失真;现只在 `resumeTask` 计 | E4 断言 |
|
||||
| 压力测试 | `scheduler_stress_test.go`:100 排队 + 100 中断(各级 25)混合;另加嵌套到 4 帧上限并验证 LIFO | E4/E5 |
|
||||
|
||||
实现期与设计的差异(均已回写本文档):
|
||||
|
||||
1. **M3 拆为 M3a/M3b**:真正挂起要求帧跨 `prepare→run→finish`,否则 `processInput`
|
||||
会在挂起返回后继续提交。
|
||||
2. **`a.mu` 整体移除**:它原本只包住整轮 `process()`(同一 goroutine),
|
||||
移除后所有任务状态由 schedulerLoop 独占(不变量 I2/I3 可落地)。
|
||||
3. **`interceptCh` 被删除**:M3b 起中断一律走中断队列(当时叫 `pendingInterrupts`),旧的
|
||||
“同行注入 + 三处 drain + 批次放弃” 已无写入者,属死代码(M4 清理)。
|
||||
4. **v1 未做 M0 的伪时钟**:所有抢占测试用“单次调用阻塞到 ctx 取消”的
|
||||
provider 达到确定性,无需注入时钟。时序型判据(老化式提升)留待需要时。
|
||||
5. **工具执行中不可抢占是被结构保证的**:让位检查只在 step 之间;
|
||||
不需要在 step 内部再判一次。
|
||||
|
||||
---
|
||||
|
||||
## 15. 开放问题(后续版本)
|
||||
|
||||
1. 异步 step + `tool.cancel`(真正让工具可抢占)。
|
||||
2. 帧落盘(跨进程/崩溃恢复)。
|
||||
3. 多 agent 并行调度。
|
||||
4. 与 `plan.md` §13.7 的 `RuntimeManager + 分组 worker` 合并(本设计是其前置)。
|
||||
|
||||
> **已更正**:早期稿写“`InjectOptions.Priority` 进入公开 SDK 已被删除”,
|
||||
> 前提是“优先级是内核内部属性、不应由插件声明”。用户澄清后该前提被推翻:
|
||||
> **L1–L3 就是给插件声明使用的**。L4 的归属后来也明确了——不是“只有
|
||||
> panic/selfip”,而是**内核 + 内核级插件**(编译期内置)都能用,用于实现
|
||||
> “立即打断”(panic、内核事件、WebUI 终止按钮)。因此公开 SDK 同时导出了
|
||||
> `PriorityL4`(附“仅内核级插件”的说明)。
|
||||
>
|
||||
> 仍**不做**的是“运维可调的策略表”(`core.agent.priority.<channel>`)——
|
||||
> 那是把调度内部属性外化成配置,与“由调用方声明自己那件事有多不能等”不同。
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 状态:**完成 v3**(2026-09-06)——v2 的迁移已上生产(内核 v1.0.0);v3 记录 v1.1.1 的公开接口**扩展**。
|
||||
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(plugindev)**,外部插件业务代码零改动,只需用新 plugindev 重编。
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(hmapdev,当时名为 plugindev)**,外部插件业务代码零改动,只需用新工具链重编。
|
||||
>
|
||||
> **结果(已验证)**:`git diff third_party/homeagent-sdk/sdk/` 全程为空;17 个 `example/*/plugin.go` 逐字节未改
|
||||
> (`git status example/` 无输出);生产 17 插件全部经子进程通道运行。
|
||||
@ -11,7 +11,7 @@
|
||||
> 它要保的是「换运行模型不动业务代码」。迁移完成后,SDK 需要能随功能演进而扩展,
|
||||
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
|
||||
>
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
|
||||
> 必须同步更新本矩阵。
|
||||
>
|
||||
> 权威编号:plan.md 第 11 节(11.1~11.9)。本文档只做接口面盘点,不做实现。
|
||||
@ -21,9 +21,9 @@
|
||||
## 一、迁移的形状(一句话)
|
||||
|
||||
```
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──plugindev c-shared──> plugin.so
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──hmapdev c-shared──> plugin.so
|
||||
homed ──dlopen──> plugin.so(C ABI bridge:51 个整数 method id)
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──plugindev go build──> plugin.bin
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──hmapdev go build──> plugin.bin
|
||||
homed ──spawn──> plugin.bin(stdio JSON-RPC + shm + eventfd)
|
||||
```
|
||||
|
||||
@ -33,8 +33,8 @@
|
||||
|---|---|---|
|
||||
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
|
||||
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go(只 import 公开 SDK) | **不动**(只重编) |
|
||||
| bridge 模板 `tools/plugindev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| bridge 模板 `tools/hmapdev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| homed `internal/plugin/cabi/`(1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2) |
|
||||
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
|
||||
|
||||
@ -300,7 +300,7 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
|
||||
|
||||
## 七、接口冻结检查点(全部已通过)
|
||||
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
验收:weather 业务代码逐字节未改(`git status example/` 无输出)。
|
||||
2. ✅ **阶段 3(共享内存)**:子进程并发改写 StageContext 丢失率 = 0%
|
||||
(`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` 与
|
||||
@ -349,7 +349,7 @@ tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/plugindev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
|
||||
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
|
||||
E2E 用例编译失败),不是靠人工检查发现的。
|
||||
@ -366,7 +366,7 @@ E2E 用例编译失败),不是靠人工检查发现的。
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`(17 个) | ✅ 17/17 通过 |
|
||||
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin` 跑 `TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE(13 例压测) |
|
||||
|
||||
### v1.2.x 的接口扩展(2026-09-12)
|
||||
@ -426,5 +426,5 @@ data URL 本身已是 base64 文本,包进二进制传输省不了空间,还
|
||||
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
|
||||
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
|
||||
- `internal/plugin/proc/capability.go` — 权限梯度(capability 组 + `withheldCapabilities`)
|
||||
- `third_party/homeagent-sdk/tools/plugindev/templates/` — 子进程运行时模板(三文件)
|
||||
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
|
||||
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具
|
||||
603
docs/zh/resident-subagent-design.md
Normal file
603
docs/zh/resident-subagent-design.md
Normal file
@ -0,0 +1,603 @@
|
||||
# 驻留式子 Agent 设计(轻量内核 · 两级记忆 · 父子中断)
|
||||
|
||||
> **前置**:本文建立在《输入调度器设计》(`docs/zh/input-scheduler-design.md`)之上。
|
||||
> 那里已经落地了:两类别输入(中断 / 排队)、四级中断优先级、可抢占、
|
||||
> 现场保存/恢复、中断栈(LIFO,结构上界 4 帧)、任务级回执、panic→L4。
|
||||
> 本文只描述**驻留式子 agent** 这一新能力,以及它对既有实现的改动。
|
||||
>
|
||||
> 标记:**[已定]**= 用户明确拍板;**[默认]**= 本文给出的可逆默认取值,实现时在提交信息里标注。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现状的子 agent 是**工作式轻量子**:`spawn_child` 起一个一次性 goroutine(临时 `msgs`),
|
||||
跑完把结果经 `selfInputCh` 回投父,然后销毁。它**没有**自己的中断机制、没有通道分配、
|
||||
没有可查询的状态面,父也无法在它运行途中干预它。
|
||||
|
||||
要让父 agent 能**长期派驻**一个下属去持续处理某类工作(一个 inputch 上的来源、一段长期目标),
|
||||
就需要一种新的子 agent:**驻留子**。它必须满足:
|
||||
|
||||
- 有自己的**轻量内核**(自己的调度器、中断机制、上下文),所以父能"打断它"、"查它"、"回收它";
|
||||
- 有自己的**记忆空间**(可写),但**改不了父的主记忆** —— 记忆入库的决策权留在父手里;
|
||||
- 与父之间有**双向、可寻址**的通道(子→父、父→指定子),且父对子的消息是**最高级中断**;
|
||||
- 有一张**可被父查询的状态面**(inputch 处理表),使父不打断它也能知道它做到哪;
|
||||
- 生命周期由父掌握:父可随时**销毁**它,父退出时**必须**销毁全部。
|
||||
|
||||
**两类子 agent 并存**[已定]:工作式轻量子**原样保留**(它是"内核自循环"的一部分,
|
||||
不是对等 agent);驻留子是新增的第二类。
|
||||
|
||||
---
|
||||
|
||||
## 2. 术语
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| **根 agent** | 进程级主 agent,拥有**完整内核**(含记忆读写、全部通道) |
|
||||
| **驻留子** | 父创建、长期驻留的子 agent,拥有**轻量内核**与**临时记忆空间** |
|
||||
| **工作式轻量子** | 现状 `spawn_child` 的一次性子任务(**不是内核实例**) |
|
||||
| **inputch** | **最基本的输入路由单位**:对"中断输入 / 排队输入"两者的高层抽象,是**路由与分配**的单位;**由插件注册,一个插件可注册多个** |
|
||||
| **outputch** | 输出通道;输出是 agent 的**主动调用**,并**可寻址到具体 agent** |
|
||||
| **main 空间** | 主记忆空间:父读写、所有子只读、全部子共享 |
|
||||
| **temp 空间** | 子临时记忆空间:该子读写、子之间互不可见 |
|
||||
| **状态面** | 子对外(对父)可查询的状态:含 **inputch 处理表**与产出 |
|
||||
| **处理表** | 按 inputch 记录每轮处理信息的状态表(子持有,父 pull) |
|
||||
| **登记表** | 父持有的全部子 agent 名录(id / 状态 / 通道 / 状态面句柄) |
|
||||
| **contextfull** | 子的上下文窗口满,产生 L4 中断通知父 |
|
||||
| **压缩 / 回收 / 销毁** | 父对 contextfull 的三种处置(见 §9) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 角色与内核形态
|
||||
|
||||
| | **根 agent** | **驻留子** | **工作式轻量子**(保留不动) |
|
||||
|---|---|---|---|
|
||||
| 内核 | 完整 | **轻量** | 无(不是内核实例) |
|
||||
| 调度器 / 四级中断 / 中断栈 | ✅ | ✅(**L4 只来自父**) | ❌ |
|
||||
| 上下文 | 完整(记忆介导) | 传统上下文(消息序列) | 临时 `msgs` |
|
||||
| 记忆 | main 读写 | **读 temp ∪ main,写 temp** | 无 |
|
||||
| 通道 | 全部 | 父**划入**的输入通道 + **授权**的输出通道 | 无(结果回投父) |
|
||||
| 插件与工具 | 全部 | **父授权,默认完整授权**[已定] | 现有剔除规则不变 |
|
||||
| 状态面 / 处理表 | — | ✅(父 pull) | ❌ |
|
||||
| 子→父 | — | 主动消息 = **L3 中断** | `injectSelfChannel` → `selfInputCh`(排队) |
|
||||
| 主→子 | — | **L4 中断**(取消当前状态 + 插入新消息) | 无 |
|
||||
| 生命周期 | 进程级 | 父持登记表;父可随时销毁;**父退出必须全部销毁** | 跑完即销毁 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 通道模型
|
||||
|
||||
### 4.1 `inputch` 的定义 [已定]
|
||||
|
||||
`inputch` **不是**通道名标签,也**不是**与 outputch 配对的东西。它是:
|
||||
|
||||
1. **最基本的输入路由单位** —— 路由粒度到此为止:比"插件"细、比"通道名字符串"实;
|
||||
2. **由插件注册,且一个插件可注册多个** —— 同一个插件的多个 inputch 是**彼此独立**的
|
||||
路由单位(可以绑给不同 agent、可以分别授权)。登记接口即现有的
|
||||
`RegisterInputChannel(name, def)`,调 N 次就是 N 个 inputch;
|
||||
3. **对"中断输入"与"排队输入"两者的高层抽象** —— 两类输入都从 inputch 进出;
|
||||
4. **路由与分配的单位**:
|
||||
- **路由**:一条输入投给哪个 inputch,就是"该由谁处理"的**既定事实**
|
||||
⇒ **路由发生在进内核之前**;
|
||||
- **分配**:inputch 是**可分配资源**(父把输入通道划给子)。
|
||||
|
||||
「中断 vs 排队」是**每条输入自己的类别**(由注入 API 决定),级别(L1–L4)也是
|
||||
**每条输入的属性**,都不是 inputch 的属性。
|
||||
|
||||
### 4.2 三个入场(沿用现有实现)
|
||||
|
||||
| 入口 | 承载 |
|
||||
|---|---|
|
||||
| `inputCh` | 外部客户端 / 插件的**排队与中断**输入(已预寻址) |
|
||||
| `interruptCh` | 中断入站(外部 / 插件 / 内核自身) |
|
||||
| `selfInputCh` | **内核自循环**:consolidation、工作式轻量子回投 |
|
||||
|
||||
### 4.3 输出是**主动调用** [已定]
|
||||
|
||||
- 异步通道(qq / 微信 / 群聊…):必须显式调用 `output_send__{通道名}` 才真正送达;
|
||||
- 同步通道(webui / cli / 终端):返回纯文本,内核把文本交给等待方 ——
|
||||
走输入事件自带的 `ResponseCh`,这是**事前定好的回程**。
|
||||
|
||||
⇒ **内核不持有"当前通道"可变状态**,**提示词也不预设 outputch**。
|
||||
(要删 `Agent.currentOutputChannel`;要删 `tooldefs.go` 里"当前输入来源通道是 X,
|
||||
对应输出门工具是 output_send__X"那两行。)
|
||||
|
||||
> 之前把这件事说成"内核路由"是错的:内核只负责**投递到既定的回程**与**执行显式的输出调用**。
|
||||
|
||||
### 4.4 通道分配:不对称 [已定]
|
||||
|
||||
- **不按对划分**。父为子:**划入若干 inputch**(单位是 inputch,可以来自同一个插件的不同 inputch)
|
||||
+ 授权**一组可用输出通道**(授权集合,不是一对一)。
|
||||
- **输出通道可寻址到具体 agent**:
|
||||
- **子 → 主**:子直接打到主(经输出通道投进主的 inputch);
|
||||
- **主 → 指定某个子**:父经输出通道投进**指定子**的 inputch。
|
||||
|
||||
### 4.5 已经落地/待落地的两件事
|
||||
|
||||
**已落地(N1a)**:inputch 登记表(归属插件 / 归属 agent / 容量 / 默认回程 / 策略)+ 共享登记表
|
||||
+ 单工具多视图总览(`input_channels`,见 §4.6)。
|
||||
|
||||
**已落地(N1b)**:
|
||||
- **输出通道授权集合**[已定:默认完整授权,父可收窄]:
|
||||
`AgentConfig.AllowedOutputs`(nil/空 = 全部)。三处过滤点必须一致,
|
||||
否则会出现"列表里看不到、按名字还能调"的裂缝:
|
||||
1. **工具表**:不为未授权的通道生成 `output_send__X`(模型看不到就不会调);
|
||||
2. **列表工具**:`output_list_channels` 只列授权的(已登记目标的会标出"目标: agent / inputch");
|
||||
3. **调用点**:凭名字直调未授权的输出门**必须被拒**(纵深防御)。
|
||||
- **输出通道 → 目标 agent 的 inputch 解析**:`ChannelRegistry.BindOutputTarget` /
|
||||
`ResolveOutputTarget`(未登记的通道由传输层 device 自行处理,如 qq/webui)。
|
||||
这是"输出可寻址到具体 agent"的数据面;真正的跨 agent 投递在 N4。
|
||||
|
||||
### 4.5.1 通道的一等化(后续要求)
|
||||
|
||||
现在 `Source` / `OutputChannel` 只是字符串标签,`IOManager.inputCh` 是**一条全局 channel**,
|
||||
`inputChannels` 只是策略表(`ChannelDef`:NoMemory / Cleaner / ContextPolicy),
|
||||
**没有归属、没有绑定、没有容量**。要实现 §4.4 需要新增:
|
||||
|
||||
- **通道注册层**:`inputch` 成为一等对象。它已经是**最基本的输入路由单位**,
|
||||
所以注册表以 **inputch 为键**(一个插件 → N 个 inputch),并给每个 inputch 带上:
|
||||
**归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析**;
|
||||
⇒ 同一个插件的两个 inputch 可以**分别划给不同 agent**、**分别限额**;
|
||||
- **输出通道 → 目标 agent 的 inputch** 的解析;
|
||||
- **授权过滤**:`output_list_channels` 只列该 agent 被授权的通道。
|
||||
|
||||
### 4.6 inputch 总览:**单工具多视图** [已定]
|
||||
|
||||
父 agent 必须能看清两件事:**有哪些 inputch 已注册(谁注册的)**、**它们是怎么划分的**。
|
||||
按用户要求构筑为**单工具多视图**(一个工具 + 一个 `view` 参数),而不是一堆小工具 ——
|
||||
视图切换比工具增殖更好用,也更省提示词预算。
|
||||
|
||||
工具:**`input_channels`**
|
||||
|
||||
| view | 内容 |
|
||||
|---|---|
|
||||
| `all`(默认) | 全部已注册 inputch:名字 / **归属插件** / 归属 agent / 容量 / 记忆策略标记 |
|
||||
| `mine` | 划给**本 agent** 的 |
|
||||
| `unassigned` | **尚未划出**的(可按需分配) |
|
||||
| `by_agent` | **划分情况总览**:按归属 agent 分组列出各自拥有哪些 inputch |
|
||||
| `detail`(需 `name`) | 单个 inputch 的全字段(注册插件 / 归属 / 容量 / 默认回程 / 记忆策略) |
|
||||
|
||||
- 未知 `view` **必须报错并列出可用值**(拼错不得被静默当成默认视图)。
|
||||
- 登记表是**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
|
||||
这样"划入/授权"才有意义(默认每个 agent 自带一份,向后兼容)。
|
||||
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
|
||||
保留已有的 Owner / Capacity / Output。
|
||||
|
||||
---
|
||||
|
||||
## 5. 记忆模型:两级空间 [已定]
|
||||
|
||||
> **适用范围**:这套"两级空间"是针对**图记忆**的。子 agent 的记忆面是
|
||||
> **传统上下文 + 图记忆**;**doc 记忆**与 **context 动态上下文**是内核独立设计的
|
||||
> 记忆能力,**只有根 agent 有**(子不可见、不可用,见 §5.5)。
|
||||
|
||||
```
|
||||
主记忆空间(main) ← 子【只读,可看到全部】;父【读写】;所有子共享
|
||||
子临时记忆空间(temp) ← 该子【读写】;每个子独立、互不可见
|
||||
|
||||
子的记忆查询 = temp ∪ main 子的记忆写入 → 只落 temp
|
||||
```
|
||||
|
||||
| | 根 agent | 驻留子 |
|
||||
|---|---|---|
|
||||
| main | **读写** | **只读**(可见全部) |
|
||||
| temp | —(它自己就是 main 的所有者) | **读写**(自己的空间) |
|
||||
| 查询范围 | main | **temp ∪ main** |
|
||||
|
||||
### 5.1 "轻量内核"的真正理由
|
||||
|
||||
不是砍功能,而是**记忆层被作用域化**:
|
||||
|
||||
- **写目标**被限定到 `temp`(子自己的命名空间);
|
||||
- **读视图**被扩成 `temp ∪ main`(两个空间的并集)。
|
||||
|
||||
完整内核的记忆层**硬绑定在单一 main 空间**上。要让每个 agent 都有自己的空间 +
|
||||
并集读视图,记忆层就必须接受**每个 agent 一份的作用域参数** —— 这就是轻量内核存在的理由。
|
||||
|
||||
### 5.2 推论:记忆写工具不禁用,而是**重定向**
|
||||
|
||||
子是**能写**的(写自己的 temp)。因此:
|
||||
|
||||
- 记忆写工具(图记忆写、文本记忆写、**文档记忆写**、**向量索引写**、**媒体落盘**)
|
||||
**不禁用,而是重定向到 temp 命名空间**;
|
||||
- 检索注入算"读",范围 `temp ∪ main`;
|
||||
- 子**不能**写 main ⇒ "哪些内容进入主记忆"的决策**结构上**只在父手里(§9.2)。
|
||||
|
||||
### 5.3 三种处置对记忆的作用
|
||||
|
||||
| 动作 | temp | main | 子 |
|
||||
|---|---|---|---|
|
||||
| **压缩** | 保留 | 不写 | 继续 |
|
||||
| **回收** | 父读 → **选中的 promote 进 main** → 丢弃 temp | 父写 | 取消 |
|
||||
| **销毁** | 直接丢弃 | 不写 | 立刻移除 |
|
||||
|
||||
### 5.5 子的记忆面:**传统上下文 + 图记忆** [已定]
|
||||
|
||||
| 记忆能力 | 根 agent | 驻留子 |
|
||||
|---|---|---|
|
||||
| **图记忆**(实体/关系/句子,含其向量检索) | ✅ main 读写 | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||||
| **doc 记忆**(文档记忆) | ✅ | ❌ **不可用**(内核独立设计的记忆能力,父专属) |
|
||||
| **context 动态上下文**(动态上下文装配/裁剪) | ✅ | ❌ **不可用**;子用**传统上下文**(纯消息序列) |
|
||||
| 蒸馏 / 归档 / consolidation | ✅ | ❌(属上述父专属能力) |
|
||||
| 文本记忆 / 知识库 / 媒体 / 社交 | ✅ | ❌(同上) |
|
||||
|
||||
⇒ 所以"轻量内核"的准确表述是:**传统上下文 + 图记忆(作用域化)** ——
|
||||
不是"记忆变轻了",而是**记忆面被裁到只剩图记忆,且图记忆被作用域化**。
|
||||
|
||||
### 5.6 实现形态:**独立存储实例**(不做 space 列)[已定]
|
||||
|
||||
轻量内核的记忆**不是**把共享记忆层加一个 `space` 维度,而是**换一套装配**:
|
||||
|
||||
```
|
||||
子的轻量内核
|
||||
├─ temp 图记忆实例(独立存储,**读写**) ← 子的一切图记忆写入落这里,与子同生共死
|
||||
└─ 主图记忆的**受限句柄**(只读) ← 子只能读
|
||||
· OpenGraphDBReadOnly:连接可读/可恢复 WAL,但 SQLite 层 `PRAGMA query_only=1`
|
||||
把一切写入直接拒掉 —— "子改不了 main" 是**结构性**保证,不靠自觉
|
||||
子的图记忆查询 = temp 实例 与 主实例 各查一次,应用层合并(并集)
|
||||
```
|
||||
|
||||
- **不碰共享记忆层**:不加 `space` 列、不做 schema 迁移、55 处 SQL 原样。
|
||||
- **隔离靠"不同存储实例"**,不靠 where 条件 —— 漏写条件也不会串台。
|
||||
- **回收时由父合入**:父读子的 temp 实例,选出要保留的记录,写进主图记忆(父有写权)。
|
||||
- **可选简化**[用户给的备选]:把"允许子写图记忆"做成 profile 开关
|
||||
(`AllowTempGraphWrite`,默认开)。设为 `false` 时子对图记忆**完全只读**,
|
||||
没有 temp 实例、没有合入 —— 代价是回收时只剩状态面/处理表可收割。
|
||||
|
||||
### 5.4 待钉的边界 [默认]
|
||||
|
||||
- temp 与 main 是**同一套记忆子系统里的命名空间**(同一批表/索引 + 一个 space 维度),
|
||||
不是独立存储 ⇒ "并集读"就是一次查询里的两个 space 条件;
|
||||
- 父**可读**子的 temp(要决定 promote 什么),属"查看状态面"的一部分;
|
||||
- temp **与子同生共死**;子之间 temp **互不可见**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 中断模型:两条独立阶梯 [已定]
|
||||
|
||||
```
|
||||
在【父的】中断阶梯上:
|
||||
子的主动消息 = L3 中断 (子主动汇报,带子标识)
|
||||
子的 contextfull = L4 中断 (资源耗尽,需父立即决策,带子标识)
|
||||
|
||||
在【子的】中断阶梯上:
|
||||
父的消息(发送消息) = L4 中断 ← 子的 L4 归父独占
|
||||
```
|
||||
|
||||
### 6.1 L4 归属通则
|
||||
|
||||
> **某个 agent 的 L4 只属于它的"内核"。**
|
||||
|
||||
- 根 agent 的内核 = 内核自身(panic / 内核事件 selfip)+ 内核级插件(WebUI 终止按钮);
|
||||
- 驻留子的内核 = **父 agent**。
|
||||
|
||||
⇒ 现有 `isKernelLevelSource`(只认编译期内置插件)**泛化为"该 agent 的上级"**,不为子开特例。
|
||||
|
||||
### 6.1.1 **父消息 = 子的 L4**(落地机制,钉死)
|
||||
|
||||
父 → 子的"发送消息"是一条 **L4 中断**,它是**子的阶梯上唯一的 L4 来源**。具体落地:
|
||||
|
||||
```
|
||||
父【发送消息】到指定子
|
||||
└─ 经输出通道寻址到该子的某个 inputch
|
||||
└─ 在该子的调度器里按 L4 登记(子的内核级来源 = 父)
|
||||
└─ 子的 L4 > 子内部一切 ⇒ 立即打断子的当前任务
|
||||
```
|
||||
|
||||
- **子内部任何来源都够不到 L4**:子自己的输入注入、工具、定时器、插件……
|
||||
一律被夹到 **≤ L3**(与根 agent 里"外部插件被夹到 L3"完全同一条规则,
|
||||
只是"内核级来源"从"编译期内置插件"变成了"父 agent")。
|
||||
- 后果一:**子内部任何东西都压不过父**(父的话是子的最高级输入)。
|
||||
- 后果二:父的"取消当前状态 + 插入新消息"因此是**确定能生效**的
|
||||
—— 不会因子内部正在跑什么而被挡住(除非子处于不可抢占临界区,
|
||||
此时按调度器既有规则在安全点生效)。
|
||||
|
||||
**子侧内核级事件如何上报**(推论,待确认):子的 panic / contextfull 属于**子侧的内核级事件**,
|
||||
但在**子的**阶梯上 L4 归父独占,所以它们不能作为"子自己的 L4";应当**上报给父**,
|
||||
在**父的阶梯上以 L4(带子标识)**出现 —— 与 §9 的 contextfull 同一条通路。
|
||||
(即:父侧 L4 = 子侧内核级事件的接收位;子侧 L4 = 父控制语的发射位。)
|
||||
|
||||
### 6.2 为什么 contextfull 是 L4 而不是 L3
|
||||
|
||||
它是**必须由父立刻决策**的场合(三个处置都与子的存续有关),且决策要读**一整个状态面** ⇒
|
||||
只能"**推信号 + 拉状态**",不能把状态塞进中断消息里。
|
||||
|
||||
---
|
||||
|
||||
## 7. 父对子的控制面(6 个动作)[已定:原语在内核,决策在父的模型]
|
||||
|
||||
| 动作 | 语义 | 子是否继续存在 | 走哪条路 |
|
||||
|---|---|---|---|
|
||||
| **创建** | 划入输入通道 + 授权输出通道 + 授权插件/工具(默认完整)+ 在固定提示词之上注入任务提示词 | — | 内核原语 |
|
||||
| **发送消息** | 经输出通道寻址到子的 inputch:**取消当前状态 + 插入新消息** | 是 | 对子 = **L4 中断** |
|
||||
| **查看** | **pull** 子的状态面(处理表 + temp 产出),**不打断**子 | 是 | 纯查询,**不走中断** |
|
||||
| **压缩** | **保留语义**:压上下文 + **清理处理表** | **是**(同一驻留子) | 见 §9 |
|
||||
| **回收** | **取消语义**:父看状态面 → 选择哪些 **promote 进 main** → **取消**该驻留子 | 否 | 见 §9 |
|
||||
| **销毁** | **立刻销毁并从登记表移除**(不收割) | 否 | 立即 |
|
||||
|
||||
- **创建/销毁/回收/查看/发送**是**父可调用的原语(工具)**;**决策**(压还是收、收哪些)
|
||||
在父的模型手里 —— 内核不替父决定。
|
||||
- **默认完整授权**[已定]:子默认拿到全部插件与工具(含输出门);
|
||||
父可在创建时**收窄**(收窄工具子集、收窄可用输出通道集合)。
|
||||
⚠️ 默认含输出门意味着**子可以直接对用户通道发消息**;若要默认收窄,改一处默认即可。
|
||||
|
||||
---
|
||||
|
||||
## 8. inputch 处理表
|
||||
|
||||
### 8.1 归属与方向
|
||||
|
||||
- **子是持有者**;父**主动查看(pull)**,**不是**推给父。
|
||||
- **内容**:按 inputch 记录**每一轮**子对该 inputch 的处理信息。
|
||||
- **存在意义**:长期驻留子的**进度可见性** —— 父不必打断它就能知道它做到哪。
|
||||
|
||||
### 8.2 写入规则
|
||||
|
||||
- 子**主动写入**时,系统**不**自动写;
|
||||
- 子**未主动写入**时,系统**自动**把该轮 inputch 对应的信息写进去;
|
||||
- ⇒ **每一轮必有记录**,父不会看到空洞。
|
||||
|
||||
[默认]"主动写入"的动作形态 = 子调用一个 `inputch_note` 类**工具**;
|
||||
自动写入在轮次结束时由内核兜底。
|
||||
|
||||
### 8.3 生命周期 = 上下文窗口
|
||||
|
||||
处理表记的是"**当前这段上下文窗口**里每轮 inputch 做了什么"。
|
||||
因此:
|
||||
|
||||
- **压缩必须清表**(窗口被压成摘要后,逐轮记录被摘要取代;留着会让父看到与当前窗口
|
||||
不对应的陈旧状态);
|
||||
- **回收不必清表**(表就是父刚读过的收割材料,子都没了,表自然作废);
|
||||
- ⇒ 处理表天然有**大小上界**(窗口多大、表最长多长),不需要额外容量策略。
|
||||
|
||||
---
|
||||
|
||||
## 9. contextfull 的处置
|
||||
|
||||
```
|
||||
子的上下文窗口满
|
||||
└─ 产生 contextfull → **L4 中断**通知父(中断信息里标明是哪个子)—— 只推信号
|
||||
└─ 父【查看】子的状态面(处理表 + temp 产出)
|
||||
├─ 【压缩】压成摘要 → 清处理表 → 子续用 (保留语义)
|
||||
├─ 【回收】选择 temp 中哪些 promote 进 main (取消语义)
|
||||
│ → 丢弃 temp → 取消该驻留子
|
||||
└─ 【销毁】立刻销毁并移除 (不收割)
|
||||
```
|
||||
|
||||
| 动作 | 语义 | 子上下文/成果 | 子 agent | 处理表 |
|
||||
|---|---|---|---|---|
|
||||
| **压缩** | **保留** | 压成摘要 | **继续存在** | **必须清理** |
|
||||
| **回收** | **取消** | 选中的 promote 进 main | **取消** | 不必清 |
|
||||
| **销毁** | 立刻销毁并移除 | 不收割 | 立刻销毁 + 出登记表 | 无关 |
|
||||
|
||||
[默认]压缩由**子的轻量内核自己执行**(它拥有自己的上下文与 LLM)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 登记表与生命周期硬约束
|
||||
|
||||
- 父持 **agent 登记表**,记录全部子:`id / 状态 / 划入的输入通道 / 授权的输出通道 /
|
||||
授权的插件与工具 / 状态面句柄`。
|
||||
- 它是**查看 · 发送 · 压缩 · 回收 · 销毁**的寻址依据。
|
||||
- **硬约束(必须写成测试)**:
|
||||
1. 父 `Stop()` ⇒ 销毁全部子(取消运行中的任务、停轻量内核、释放其通道),
|
||||
**登记表清空、不留孤儿**;
|
||||
2. **子不得比父活得久**(无孤儿 goroutine / 无悬空通道 / 无残留 temp)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 并发与不变量
|
||||
|
||||
沿用输入调度器的并发模型,并按多 agent 扩展:
|
||||
|
||||
- **每个 agent 一个调度器 goroutine**(根 agent 与每个驻留子各一个),
|
||||
它**独占**自己的队列 / running / 中断栈 / 帧。
|
||||
- 跨 agent 投递只经**通道**(值传递),**不共享帧**;父**永远不能**直接改子的帧。
|
||||
- **子不得比父活得久**(§10)。
|
||||
- 父的"查看"是**只读快照**,不阻塞子、不参与子的调度决策。
|
||||
- **L4 独占**:子的调度器只接受来自父的 L4(§6.1)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 与现有实现的接合点(差距清单)
|
||||
|
||||
| 设计项 | 现状 | 要做 |
|
||||
|---|---|---|
|
||||
| 两类别 + 四级中断 + 抢占/挂起/恢复/中断栈 | ✅ 已落地(见 input-scheduler-design.md) | 复用 |
|
||||
| inputch 一等化(归属/容量/授权) | ❌ `inputCh` 是全局单 channel;`ChannelDef` 只是策略表 | **新增通道注册与分配层** |
|
||||
| 输出通道可寻址到 agent | ❌ 只有字符串标签;`output_list_channels` 列全部 | 通道解析表 + 授权过滤 |
|
||||
| 子的 L4 = 父 | ⚠️ `isKernelLevelSource` 只认内置插件 | 泛化为"该 agent 的上级"(分层) |
|
||||
| 轻量内核(记忆作用域化) | ❌ 记忆层绑定单一 main 空间 | 记忆子系统加 **space 维度**;`AgentConfig` 加**作用域参数** |
|
||||
| 记忆写路径重定向到 temp | ❌ 写路径无空间概念 | 所有写入口带 space;子的一切写 → temp |
|
||||
| 驻留子生命周期 + 登记表 | ❌ 只有一次性 `runChildTask` | 驻留子 + 父的登记表 + 退出清理 |
|
||||
| 跨 agent 投递(子→父 L3 / 父→子 L4) | ❌ 无 | 投递原语(复用注入层 + 通道寻址) |
|
||||
| inputch 处理表 | ❌ 无 | 新数据结构 + 主动写入工具 + 自动写兜底 |
|
||||
| contextfull 检测 | ❌ **完全没有** | 检测 + L4 通知(带子标识)+ 三处置 |
|
||||
| 内核不持有"当前通道" | ❌ `currentOutputChannel` + 提示词预设 | 删字段、删预设 |
|
||||
| 工作式轻量子 | ✅ | **不动** |
|
||||
|
||||
### 16.0 N2c 施工方案(轻量内核接线)[已定方案:**窄接口 + nil 即禁用**]
|
||||
|
||||
**先按"谁在调"把 `a.memory` 的 42 处使用分类**(`grep` 实测,非估计):
|
||||
|
||||
| 分组 | 位置 | 方法 | 谁用 |
|
||||
|---|---|---|---|
|
||||
| **A 记忆整理流水线** | `distill.go`(10):`archiveLoop` / `reviewLoop` / `mergeLoop` / `detectEntityMerge` / `reviewRelations` / `archiveColdDocs` | `Recall`, `ClearSentenceID`, `CleanupOrphanedSentences` | **root-only**(后台定时器) |
|
||||
| **B 记忆块 + 媒体桥** | `graphmedia.go`(18)、`medialoop.go`(4) | `PutMemoryBlocks`, `AddMemoryBlockEdge`, `BlocksForNode`, `PutDocumentNode`, `MemoryBlocks`, `MigrateLegacyMediaEntities`, `mediaContextFor*` | **root-only** |
|
||||
| **C 记忆整理工具** | `toolcall.go::executeMemoryTool`(8) | `Introspect`, `MergeEntities`, `DeleteEntity`, `Purge`, `Commit` | **root-only**(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`) |
|
||||
| **D 共同面** | `graphmedia.go:114`(自动写入)、`toolcall.go:130`(`memory_recall`) | **只有 `Recall` + `Commit`** | 根与子都要 |
|
||||
| **E 判空/状态** | 22 处 `if a.memory != nil` + `GetKernelStatus` + `buildToolDefs` | — | 既有关卡 |
|
||||
|
||||
⇒ **子 agent 需要的记忆面只有 `Recall` + `Commit`**;其余全是"整理记忆 / 记忆整理流水线"
|
||||
(用户指出的关键点),**子根本不该有那些代码路径**。
|
||||
|
||||
#### 设计:窄接口 + nil 即禁用(不写"18 个方法返回错误"的受限包装)
|
||||
|
||||
```go
|
||||
// core 内部:共同面(根与子都要)
|
||||
type GraphMemory interface {
|
||||
Recall(keywords, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
|
||||
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
|
||||
}
|
||||
```
|
||||
|
||||
| | `a.graph`(共同面) | `a.memory`(整理面:块/媒体/流水线/整理工具) |
|
||||
|---|---|---|
|
||||
| **根 agent** | 同一个 `*GraphDB` | `*GraphDB` |
|
||||
| **驻留子** | `*LightMemory` | **`nil`** |
|
||||
|
||||
- 子把 `a.memory` 设为 `nil` ⇒ **既有的 22 处 nil 关卡自动禁掉全部 root-only 路径**
|
||||
(`executeMemoryTool` 开头已经是 `if a.memory == nil { return "图记忆系统不可用" }`)。
|
||||
- 唯一要拆的是**自动写入路径** `commitTriplesWithMedia`:
|
||||
图部分 → `a.graph.Commit`;块/媒体部分 → 由 `a.memory != nil` 守卫。
|
||||
- 工具表:`memory_recall`(读)对子开放;整理类
|
||||
(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`)
|
||||
**不进子的工具表**(而不是让它们进去再报"不可用")。
|
||||
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
||||
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。
|
||||
|
||||
### 16.0.0 传统上下文的实现口径(子 vs 父)
|
||||
|
||||
"传统上下文"不是一句口号,它对应三处**代码闸门**(都按 `isLightKernel()` 判):
|
||||
|
||||
| 能力 | 父(完整内核) | 子(轻量内核) | 闸门位置 |
|
||||
|---|---|---|---|
|
||||
| 时间线拼装预算 | `budget.ContextTokens`(动态上下文算出的份额,≈窗口 32%~53%) | **整个窗口** `budget.MaxContext` | `contextTokenBudget()`(`stepPrepare` 与 `rebaseFramePrefix` 两处) |
|
||||
| 按相关度裁剪 + 向 doc 记忆归档 | 通道/注入点声明 `context_policy=prune` 时执行 | **不执行** | `pruneOnInput()` 前置返回 |
|
||||
| doc 记忆 / 记忆整理流水线 | 有 | 无(`a.memory == nil` ⇒ 既有 22 处关卡自动关闭) | `memoryface.go` / `tooldefs.go` |
|
||||
|
||||
**"不裁"的准确含义**:不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个
|
||||
**硬上限**约束;而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ——
|
||||
**丢事件的决定权在父,不在内核**(父可压缩/回收/销毁)。
|
||||
|
||||
**顺带修掉的既有 bug**:`formatMergedTimeline` 逐事件估算原用 `len()`(**字节**)再 ×2,
|
||||
而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍,窗口还有余量也提前 break、
|
||||
把更早事件整段丢掉(实测 2384 字中文事件被估成 14398 token > 8192)。已改为统一的
|
||||
`EstimateTokens`。这条 bug 对父同样有效(中文长会话会被过早裁剪)。
|
||||
|
||||
### 16.0.1 工具面(已实现)
|
||||
|
||||
| 工具 | 谁用 | 作用 |
|
||||
|---|---|---|
|
||||
| `resident_agents` | 父 | **单工具多动作**:`list` / `create`(划入 inputch + 授权输出通道 + 注入任务提示词)/ `send`(对子 = L4)/ `inspect`(pull 处理表,不打断)/ `compress`(保留)/ `reclaim`(取消 + 合入)/ `destroy` |
|
||||
| `notify_parent` | 子 | 主动汇报(父侧 = **L3 中断**) |
|
||||
| `inputch_note` | 子 | 主动写本轮 inputch 处理信息(写了就不自动写) |
|
||||
|
||||
> 声明是条件式的:父(`parentID == ""`)才有 `resident_agents`;子才有 `notify_parent` / `inputch_note`。
|
||||
|
||||
### 16.1 N2 的记忆面清单(现状)
|
||||
|
||||
`internal/memory/` 下需要加 space 维度的面:
|
||||
|
||||
| 面 | 载体 | 表 | 子 agent 可用? |
|
||||
|---|---|---|---|
|
||||
| **图记忆** | `memory.GraphDB` | `entities` / `sentences` / `relations` | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||||
| **图记忆的向量检索** | `memory.Indexer` | (索引侧,与图记忆同步) | ✅ 同图记忆(需按 space 过滤) |
|
||||
| 文档记忆(doc 记忆) | `document.Store` | `documents` | ❌ 父专属 |
|
||||
| context 动态上下文 | 内核上下文装配/裁剪(`pruneOnInput` 等) | — | ❌ 父专属;子用传统上下文 |
|
||||
| 知识库 | `knowledge.Store` | 各自表 | ❌ |
|
||||
| 文本记忆 | `text.Memory` | 各自表 | ❌ |
|
||||
| 媒体 | `media.Store` | 落盘 + 索引 | ❌ |
|
||||
| 社交 | `social` | 各自表 | ❌ |
|
||||
|
||||
⇒ N2a 做**图记忆 + 其向量检索**这一条纵切(子唯一可用的记忆面,
|
||||
也是"子写 temp / 父 promote 进 main"的主战场);其余面在 v1 **不加 space 维度**
|
||||
(子根本够不到,加了是白工)——若将来子扩展记忆面再逐面补。
|
||||
|
||||
---
|
||||
|
||||
## 13. 非目标(本文明确不做)
|
||||
|
||||
1. 跨进程 / 跨主机的驻留子(v1 只在同进程内)。
|
||||
2. 子的**子**(驻留子再创建驻留子)——先不做,保留扩展位。
|
||||
3. main 空间的**多写者**(父是唯一写者,不做并发合并)。
|
||||
4. temp 空间的持久化(跟子同生共死,不落盘)。
|
||||
5. 工作式轻量子的任何行为变更。
|
||||
|
||||
---
|
||||
|
||||
## 14. 测试点、方式与预期
|
||||
|
||||
| 编号 | 测试点 | 方式 | 预期 |
|
||||
|---|---|---|---|
|
||||
| S1 | 创建:划入输入通道 + 授权输出通道 | 创建子,向划入的 inputch 投输入 | 子处理它;未划入的 inputch 投不进(或报错) |
|
||||
| S2 | 授权收窄 | 创建时只授权部分工具/插件 | 子的工具表恰为该子集;`output_list_channels` 只列授权的 |
|
||||
| S3 | 默认完整授权 | 不传授权参数创建 | 子拿到全部插件/工具 |
|
||||
| S4 | 子→父 L3 | 子在工作中主动发消息 | 父侧收到 **L3 中断**且带子标识;父可被打断(非临界区时) |
|
||||
| S5 | contextfull → L4 | 灌满子的上下文 | 父侧收到 **L4 中断**、带子标识,且**只推信号** |
|
||||
| S6 | 父→子 L4 | 父"发送消息"到指定子 | 子在收到时被打断(L4 > 子内部一切),按 §15 的默认挂起/恢复 |
|
||||
| S7 | 查看(pull 不打断) | 子在跑长任务时父"查看" | 返回状态面快照;**子的 step/帧不变**、未被抢占 |
|
||||
| S8 | 处理表:自动写兜底 | 子一轮不主动写 | 该轮仍有记录(系统自动写) |
|
||||
| S9 | 处理表:主动写优先 | 子主动写 `inputch_note` | 该轮只有主动写的内容,无自动写 |
|
||||
| S10 | 压缩(保留语义) | 父选压缩 | 上下文变短;**处理表被清空**;**子继续存在**且能继续干活 |
|
||||
| S11 | 回收(取消语义) | 父选回收并挑若干条 promote | 选中内容进 **main**、其余丢弃;**temp 被丢弃**;**子被取消** |
|
||||
| S12 | 销毁(立刻) | 父销毁(含子正在跑工具/LLM 时) | 子立刻消失、出登记表、其 temp 丢弃、通道释放 |
|
||||
| S13 | 子不得写 main | 子调记忆写工具 | 落在 **temp**;main 无新增 |
|
||||
| S14 | 子查询范围 = temp ∪ main | 子查只在 main 里的内容 / 只在 temp 里的内容 | 两者都能查到 |
|
||||
| S15 | 子之间 temp 隔离 | 两个子各写 temp,互相查 | 查不到对方的 temp |
|
||||
| S16 | 父退出清理 | 父 `Stop()`(多个子、有子在工作中) | 全部子被销毁;登记表空;**无孤儿 goroutine / 无悬空通道 / 无残留 temp** |
|
||||
| S17 | L4 独占 | 子内部(子自己的输入/工具/定时器)试图产生 L4 | 被夹到 **≤L3**;只有父的消息是 L4 |
|
||||
| S21 | 一个插件多个 inputch 可分别路由 | 同一插件注册 2 个 inputch,分别划给父与子后各投一条输入 | 各自只到被划给的 agent,互不串台 |
|
||||
| S22 | inputch 总览(单工具多视图) | 一个插件注册 2 个 inputch、另一插件 1 个;把其中若干划给本 agent | `view=all` 列出全部(带归属插件);`mine`/`unassigned` 各自正确;`by_agent` 给出划分总览;`detail` 给出单条全字段;未知 view 报错并列出可用值 |
|
||||
| S23 | 插件重载不抹划分 | 先划分 inputch,再重复登记(模拟插件重载) | Owner/Capacity 保留,仅策略被更新 |
|
||||
| S19 | 父消息必能打断子 | 子在长任务中(LLM 流式段)时父发送消息 | 子按 L4 被打断;若子在不可抢占临界区,则在安全点生效 |
|
||||
| S20 | 子的内核级事件上报 | 子 panic / 子 contextfull | 在**父的阶梯上以 L4(带子标识)**出现;子侧不自己产生 L4 |
|
||||
| S18 | 内核不持有"当前通道" | 抢占/中断后被打断任务恢复并发响应 | 提示词与事件标签都**只来自输入事件**(不再有被覆盖的字段) |
|
||||
|
||||
---
|
||||
|
||||
## 15. 待确认决策(含默认取值)
|
||||
|
||||
| 编号 | 问题 | 取值 |
|
||||
|---|---|---|
|
||||
| **R1** | 驻留子的内核形态 | **[已定]独立轻量内核**(自己的调度器/中断栈/上下文/记忆作用域) |
|
||||
| **R2** | 插件与工具 | **[已定]父授权,默认完整授权**(可收窄) |
|
||||
| **R3** | 记忆模型 | **[已定]两级空间**:读 temp∪main,写 temp(**范围 = 图记忆**) |
|
||||
| **R3b** | 子的记忆面 | **[已定]传统上下文 + 图记忆**;doc 记忆与 context 动态上下文是**父专属**(内核独立设计的记忆能力) |
|
||||
| **R13** | inputch 总览的形态 | **[已定]单工具多视图**(`input_channels` + `view`) |
|
||||
| **R11** | 父消息的级别 | **[已定]对子 = L4**(子的阶梯上唯一 L4 来源;子内部一律 ≤L3) |
|
||||
| **R12** | 子的内核级事件(panic / contextfull)上报级别 | **[默认/推论]在父的阶梯上以 L4(带子标识)上报** |
|
||||
| **R4** | 父→子消息落地 | **[默认]挂起/恢复**(现场不丢);一处开关可改"直接丢弃" |
|
||||
| **R5** | "主动写入处理表"的形态 | **[默认]子调用 `inputch_note` 类工具**;未调用则轮末自动写 |
|
||||
| **R6** | 输入通道"划入"的语义与容量 | **[默认]读写授权(不转移所有权)+ 创建时给定容量**;**划入单位 = inputch**(不是插件、不是通道组) |
|
||||
| **R7** | 压缩由谁执行 | **[默认]子的轻量内核自己压**(它有自己的上下文与 LLM),压缩后清表 |
|
||||
| **R8** | 压缩前父是否"查看后决定" | **[默认]纯机械压缩**(父只在选"压缩 vs 回收"时决策) |
|
||||
| **R9** | 回收时处理表 | **[默认]不必清**(子都没了);若日后要留作审计需单独策略 |
|
||||
| **R10** | 默认授权是否含输出门 | **[默认]含**("默认完整授权"的字面含义);若嫌宽,改默认即可 |
|
||||
|
||||
---
|
||||
|
||||
## 16. 实现里程碑(每步 = 一个可独立验收的提交)
|
||||
|
||||
| 里程碑 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| **N0** | **无状态化**:删 `Agent.currentOutputChannel`、删提示词里的通道预设 | S18;既有全部测试通过(这是纯收敛,不含新能力) |
|
||||
| **N1a** | **通道登记层**:inputch 一等化(归属插件 / 归属 agent / 容量 / 共享登记表)+ **单工具多视图总览** | S21–S23 |
|
||||
| **N1b** | 输出通道授权过滤 + 目标解析(outputch → 目标 agent 的 inputch) | S1–S3 |
|
||||
| **N2a** | ~~作用域对象 + 图记忆 space 维度~~ **已完成(改为独立存储实例)**:`OpenGraphDBReadOnly`(query_only 受限句柄)+ `LightMemory`(temp 可写 / 主库只读 / 并集查询 + 应用层合并) | S13–S15 ✅ |
|
||||
| **N2b** | 图记忆的向量检索在并集下的排序/去重(当前按实体名/三元组合并,检索排序沿用单库语义) | 待做(非阻塞) |
|
||||
| — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
|
||||
| **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ |
|
||||
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ |
|
||||
| **N3** | ~~驻留子生命周期~~ **已完成**:`SpawnResident` / `DestroyResident` / `Residents()`(登记表)/ `Stop()` 内 `StopResidents()`(父退出不留孤儿)/ 归还划入的 inputch / 丢弃 temp 目录 | S12、S16 ✅ |
|
||||
| **N4** | ~~跨 agent 投递~~ **已完成**:子→父 `notify_parent`(L3,投父的 `child/<id>` inputch);父→子 `SendToResident`(L4,`KernelSource` 分层使父在子的阶梯上是唯一 L4 来源);子的 contextfull 经 `raiseKernelInterrupt` 以 L4 上报父 | S4、S6、S17、S19、S20 ✅ |
|
||||
| **N5** | ~~inputch 处理表~~ **已完成**:`inputch_note`(主动写优先)+ `autoRecordInputch`(轮末兜底)+ `CompressResident` 清表 + 父 `ResidentTable(id)` pull 查看 | S8、S9 ✅ |
|
||||
| **N6** | ~~contextfull~~ **已完成**:判据 = **未裁剪的积累上下文**超过窗口 90%(不能用拼好的 `f.Msgs`——它被 token 预算封在 ~80% 窗口内,是永不成立的判据);通知 = 父侧 L4(`child/<id>`);三处置 = `CompressResident`(保留:`TrimKeepRecent` + 清表)/ `ReclaimResident`(取消:`ExportTriples` 选出后 `Commit` 进 main)/ `DestroyResident` | S5、S10、S11 ✅ |
|
||||
| **N7** | ~~e2e + 压力~~ **已完成**:`resident_test.go` 五项(生命周期/双向投递/处理表/contextfull 三处置/8 子×12 轮压力 + 双向汇报),`-race -count=3` 干净 | S1–S20 覆盖 ✅ |
|
||||
|
||||
每步收尾命令:
|
||||
|
||||
```bash
|
||||
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp
|
||||
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
|
||||
go build ./... && go vet ./...
|
||||
go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal/plugin/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 与发布纪律的关系
|
||||
|
||||
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
|
||||
- 若需要动公开 SDK(例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
|
||||
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。
|
||||
@ -26,19 +26,47 @@ import (
|
||||
// ContextEvent 和 RelevanceContext 定义在 context.go
|
||||
|
||||
// Agent — 单 agent,不区分会话/实例
|
||||
//
|
||||
// 并发现状(M3a 起):所有任务状态只由 **schedulerLoop goroutine** 独占读写,
|
||||
// 因此不再有保护整轮执行的互斥量——挂起不能持锁(见 docs/zh/input-scheduler-design.md §8.1 I3)。
|
||||
// 仍需跨 goroutine 保护的是:childMu/llmMu/lastInputMu/noMergeMu 与各子系统自己的锁;
|
||||
// interceptLoop 只允许触碰 preemptionRequest 与 cancelLLM(经 llmMu)。
|
||||
type Agent struct {
|
||||
mu sync.Mutex
|
||||
id types.AgentID
|
||||
provider agentAPI.Provider
|
||||
providerManager *agentAPI.ProviderManager
|
||||
io *agentIO.IOManager
|
||||
memory *memory.GraphDB
|
||||
indexer *memory.Indexer
|
||||
tracker *tracker.Tracker
|
||||
context *RelevanceContext
|
||||
systemPrompt string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。
|
||||
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
|
||||
graph GraphMemory
|
||||
|
||||
// kernelSource/parentID/taskPrompt/dataDir:驻留子相关的层级信息(见 AgentConfig)。
|
||||
kernelSource string
|
||||
parentID string
|
||||
taskPrompt string
|
||||
dataDir string
|
||||
|
||||
// 驻留子(父侧):登记表 + 子侧钩子。
|
||||
residentMu sync.Mutex
|
||||
residents map[string]*residentChild
|
||||
|
||||
// 子侧:向父发消息(L3)与 contextfull 上报(父侧内核级事件)的钩子。
|
||||
notifyParent func(text string)
|
||||
onContextFull func()
|
||||
ctxFullSignaled bool
|
||||
|
||||
// 子侧:inputch 处理表(子持有,父 pull)。
|
||||
tableMu sync.Mutex
|
||||
inputchTable []InputchRecord
|
||||
inputchPending *InputchRecord
|
||||
currentInputch string
|
||||
indexer *memory.Indexer
|
||||
tracker *tracker.Tracker
|
||||
context *RelevanceContext
|
||||
systemPrompt string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// 文档记忆(第二层)
|
||||
docStore *document.Store
|
||||
@ -57,9 +85,16 @@ type Agent struct {
|
||||
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
|
||||
mediaStore *media.Store
|
||||
|
||||
// 人格设定
|
||||
// 人格设定(内容来自启动时载入的人格文件/配置项)
|
||||
personality *agentPkg.Personality
|
||||
|
||||
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go)。
|
||||
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
|
||||
personaStore PersonaStore
|
||||
|
||||
// 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs)。
|
||||
allowedOutputs []string
|
||||
|
||||
// 插件注册表(用于 plgreload)
|
||||
pluginReg *plugin.Registry
|
||||
pluginDir string
|
||||
@ -76,7 +111,6 @@ type Agent struct {
|
||||
maxContextSize int
|
||||
|
||||
// 当前请求的输出通道(mutex 保护,process() 内独占)
|
||||
currentOutputChannel string
|
||||
|
||||
// 阶段管道:插件消息流编辑
|
||||
stageHost *StageHost
|
||||
@ -102,8 +136,12 @@ type Agent struct {
|
||||
// childSeq 给完成的任务排个序,用于有界淘汰。
|
||||
childSeq int64
|
||||
|
||||
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
||||
interceptCh chan *agentIO.InputEvent
|
||||
// 输入调度器:就绪队列、任务抽象与快照(见 scheduler.go)。
|
||||
// M2 起取代 eventLoop 的隐式 channel 排队。
|
||||
sched *scheduler
|
||||
|
||||
// 工具轮次硬上限(0 = 不限);见 AgentConfig.MaxToolTurns。
|
||||
maxToolTurns int
|
||||
|
||||
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
|
||||
cancelLLM context.CancelFunc
|
||||
@ -124,7 +162,7 @@ type Agent struct {
|
||||
//
|
||||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
||||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与 pendingMedia 同受 a.mu 保护。
|
||||
// 由 schedulerLoop goroutine 独占读写。
|
||||
pendingMediaDigests []string
|
||||
|
||||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||||
@ -150,6 +188,12 @@ type Agent struct {
|
||||
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
|
||||
multimodalSpace vector.MultimodalEmbedder
|
||||
|
||||
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
|
||||
// embeddingError 是打开/适配失败的原因(成功时为空)。
|
||||
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
|
||||
embeddingProvider string
|
||||
embeddingError string
|
||||
|
||||
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
|
||||
fusionCfg CrossModalFusionConfig
|
||||
|
||||
@ -172,18 +216,45 @@ type AgentConfig struct {
|
||||
ProviderManager *agentAPI.ProviderManager
|
||||
IO *agentIO.IOManager
|
||||
Memory *memory.GraphDB
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
// LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 temp∪main,只写 temp)。
|
||||
//
|
||||
// 给了它就意味着这是轻量内核:`Memory` 必须为 nil,
|
||||
// 于是记忆整理面(块/媒体/流水线/整理工具)全部不可达(见 memoryface.go)。
|
||||
LightMemory *memory.LightMemory
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
Personality *agentPkg.Personality
|
||||
PluginReg *plugin.Registry
|
||||
DocStore *document.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
MediaStore *media.Store
|
||||
MultimodalSpace vector.MultimodalEmbedder
|
||||
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
|
||||
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
|
||||
EmbeddingProvider string
|
||||
EmbeddingError string
|
||||
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
|
||||
Personality *agentPkg.Personality
|
||||
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
||||
PluginReg *plugin.Registry
|
||||
// KernelSource 是本 agent 的"上级"(驻留子的父)。
|
||||
//
|
||||
// 设计 §6.1:某个 agent 的 L4 只属于它的**内核** —— 根 agent 的内核是内核自身与
|
||||
// 内核级插件;驻留子的内核是**父 agent**。因此子的 KernelSource = 父 ⇒ 只有父
|
||||
// 能在子的阶梯上产生 L4(父的"发送消息")。
|
||||
KernelSource string
|
||||
// ParentID 是父 agent 的 id(空 = 根 agent)。子用它判断自己是不是驻留子。
|
||||
ParentID string
|
||||
// DataDir 是本 agent 的数据目录;创建驻留子时用它派生 temp 图记忆路径。
|
||||
DataDir string
|
||||
// TaskPrompt 是在固定提示词之上注入的**任务提示词**(驻留子创建时给定)。
|
||||
TaskPrompt string
|
||||
// AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2)。
|
||||
//
|
||||
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
|
||||
// 父 agent 创建驻留子时用它收窄子的输出能力。
|
||||
AllowedOutputs []string
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||||
@ -200,6 +271,10 @@ type AgentConfig struct {
|
||||
SkillIndexProvider SkillIndexProvider
|
||||
|
||||
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
||||
|
||||
// MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。
|
||||
// 设计文档 D6:主循环必须有硬上限,否则模型不停调用就永不完结。
|
||||
MaxToolTurns int
|
||||
}
|
||||
|
||||
func New(cfg AgentConfig) *Agent {
|
||||
@ -251,45 +326,55 @@ func New(cfg AgentConfig) *Agent {
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
personality: cfg.Personality,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
multimodalSpace: cfg.MultimodalSpace,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
mediaStore: cfg.MediaStore,
|
||||
personality: cfg.Personality,
|
||||
personaStore: cfg.PersonaStore,
|
||||
allowedOutputs: cfg.AllowedOutputs,
|
||||
pluginReg: cfg.PluginReg,
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
archiveInterval: cfg.ArchiveInterval,
|
||||
reviewInterval: cfg.ReviewInterval,
|
||||
mergeInterval: cfg.MergeInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
sched: newScheduler(256),
|
||||
maxToolTurns: cfg.MaxToolTurns,
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
embedder: embedder,
|
||||
multimodalSpace: cfg.MultimodalSpace,
|
||||
embeddingProvider: cfg.EmbeddingProvider,
|
||||
embeddingError: cfg.EmbeddingError,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,7 +382,7 @@ func New(cfg AgentConfig) *Agent {
|
||||
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
|
||||
|
||||
func (a *Agent) Start() {
|
||||
go a.eventLoop()
|
||||
go a.schedulerLoop()
|
||||
go a.interceptLoop()
|
||||
go a.distillLoop()
|
||||
go a.archiveLoop()
|
||||
@ -309,11 +394,68 @@ func (a *Agent) Start() {
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
a.cancel()
|
||||
}
|
||||
|
||||
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
|
||||
//
|
||||
// - 轻量内核(给了 LightMemory):用 LightMemory,**整理面保持 nil**;
|
||||
// - 完整内核:直接用主图库(*memory.GraphDB 天然满足 GraphMemory)。
|
||||
func graphMemoryOf(cfg AgentConfig) GraphMemory {
|
||||
if cfg.LightMemory != nil {
|
||||
return cfg.LightMemory
|
||||
}
|
||||
if cfg.Memory == nil {
|
||||
return nil
|
||||
}
|
||||
return cfg.Memory
|
||||
}
|
||||
|
||||
// graphMem 返回本 agent 的图记忆**共同面**。
|
||||
//
|
||||
// `graph` 显式为 nil 时回落到 `memory` —— 这样"只设 memory 的构造"
|
||||
// (大量既有测试直接用 Agent 字面量)照常工作,不需要同时维护两个字段。
|
||||
// 轻量内核则显式设 graph=LightMemory 且 memory=nil:共同面走 LightMemory,
|
||||
// 整理面因 memory==nil 而全部不可达。
|
||||
func (a *Agent) graphMem() GraphMemory {
|
||||
if a.graph != nil {
|
||||
return a.graph
|
||||
}
|
||||
if a.memory == nil {
|
||||
return nil
|
||||
}
|
||||
return a.memory
|
||||
}
|
||||
|
||||
func (a *Agent) ID() types.AgentID { return a.id }
|
||||
|
||||
// IsOutputAllowed 报告某个输出通道是否被授权给本 agent。
|
||||
//
|
||||
// 默认(未配置白名单)= **完整授权**;这是"默认完整授权、父可收窄"的落点。
|
||||
func (a *Agent) IsOutputAllowed(channel string) bool {
|
||||
if len(a.allowedOutputs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, c := range a.allowedOutputs {
|
||||
if c == channel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveOutputTarget 解析输出通道的投递目标(agent + inputch)。
|
||||
//
|
||||
// ok=false 表示该输出通道由传输层(device 通道,如 qq/webui)自行处理。
|
||||
func (a *Agent) ResolveOutputTarget(channel string) (agentIO.OutputTarget, bool) {
|
||||
if a.io == nil || a.io.ChannelRegistry() == nil {
|
||||
return agentIO.OutputTarget{}, false
|
||||
}
|
||||
return a.io.ChannelRegistry().ResolveOutputTarget(channel)
|
||||
}
|
||||
|
||||
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
|
||||
// key=source+"|"+content;窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
|
||||
const duplicateInputWindow = 10 * time.Second
|
||||
|
||||
@ -451,6 +451,29 @@ func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
|
||||
return out
|
||||
}
|
||||
|
||||
// TrimKeepRecent 只保留最近 n 条事件,丢弃更旧的(返回丢弃条数)。
|
||||
//
|
||||
// 这是**压缩上下文**(保留语义)的机械原语:不归档、不写任何记忆,直接丢弃旧事件。
|
||||
// 用于轻量内核(驻留子):它没有 doc 记忆与记忆整理流水线,压缩只能是"保留最近的"。
|
||||
func (c *RelevanceContext) TrimKeepRecent(n int) int {
|
||||
c.mu.Lock()
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if len(c.events) <= n {
|
||||
c.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
dropped := len(c.events) - n
|
||||
kept := make([]*ContextEvent, n)
|
||||
copy(kept, c.events[dropped:])
|
||||
c.events = kept
|
||||
c.dirty = true
|
||||
c.mu.Unlock()
|
||||
c.save()
|
||||
return dropped
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
92
internal/agent/core/device_tool_auth_test.go
Normal file
92
internal/agent/core/device_tool_auth_test.go
Normal file
@ -0,0 +1,92 @@
|
||||
package core
|
||||
|
||||
// 设备类工具的**授权闸**:设备指令类工具走的是工具面,而 AllowedOutputs 只作用于
|
||||
// output_send__<通道> —— 不补闸的话"授权"对指令类完全无效(驻留子拿到
|
||||
// device_ctl_cmdrun 就能指挥任意设备)。这里按目标设备的通道名 device/<id> 查同一道闸。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// registerFakeDevice 注册一台假设备,带一个"需要 device_id 的指令类工具"和一个无参枚举工具。
|
||||
func registerFakeDevice(t *testing.T, a *Agent, name string, called *[]string) {
|
||||
t.Helper()
|
||||
dev := &mockOutputDevice{
|
||||
name: name,
|
||||
caps: agentIO.CapStructured,
|
||||
tools: []agentIO.ToolDef{
|
||||
{Name: "device_ctl_cmdrun", Description: "在设备上执行命令"},
|
||||
{Name: "devicedetect", Description: "枚举设备"},
|
||||
},
|
||||
toolFn: func(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
if called != nil {
|
||||
*called = append(*called, tool)
|
||||
}
|
||||
return "ok:" + tool, nil
|
||||
},
|
||||
}
|
||||
if err := a.io.RegisterDevice(dev); err != nil {
|
||||
t.Fatalf("注册测试设备 %s 失败: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func deviceToolCall(name string, args map[string]interface{}) agentAPI.ToolCall {
|
||||
return agentAPI.ToolCall{ID: "call_1", Name: name, Arguments: args}
|
||||
}
|
||||
|
||||
// 完整授权(根 agent 默认):设备指令工具照常可用。
|
||||
func TestDeviceToolAuth_RootHasFullGrant(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
var called []string
|
||||
registerFakeDevice(t, a, "devicectl", &called)
|
||||
|
||||
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||
"device_id": "pc-1", "command": "ls",
|
||||
}), "cli")
|
||||
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
|
||||
t.Fatalf("根 agent 应可指挥任意设备,实际: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 收窄授权(驻留子):只授权了 device/ok-1,指挥别的设备必须被拒,且**不落到设备**。
|
||||
func TestDeviceToolAuth_NarrowedGrantRefusesOtherDevice(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.allowedOutputs = []string{"device/ok-1"}
|
||||
var called []string
|
||||
registerFakeDevice(t, a, "devicectl", &called)
|
||||
|
||||
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||
"device_id": "other-2", "command": "rm -rf /",
|
||||
}), "cli")
|
||||
if !strings.Contains(got, "未授权") {
|
||||
t.Fatalf("未授权设备应被拒,实际: %s", got)
|
||||
}
|
||||
if len(called) != 0 {
|
||||
t.Fatalf("被拒的调用不得落到设备,实际执行了 %v", called)
|
||||
}
|
||||
|
||||
// 已授权的设备照常可用
|
||||
got = a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
|
||||
"device_id": "ok-1", "command": "ls",
|
||||
}), "cli")
|
||||
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
|
||||
t.Fatalf("已授权设备应可用,实际: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 无参枚举类(devicedetect)不受闸门影响:它不指向具体设备。
|
||||
func TestDeviceToolAuth_EnumerationNotGated(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.allowedOutputs = []string{"device/ok-1"}
|
||||
var called []string
|
||||
registerFakeDevice(t, a, "devicectl", &called)
|
||||
|
||||
got := a.executeToolCall(deviceToolCall("devicedetect", map[string]interface{}{}), "cli")
|
||||
if !strings.Contains(got, "ok:devicedetect") {
|
||||
t.Fatalf("枚举类工具不应被设备授权闸拦,实际: %s", got)
|
||||
}
|
||||
}
|
||||
@ -495,7 +495,6 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
|
||||
|
||||
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||
start := time.Now()
|
||||
a.currentOutputChannel = "_consolidation_"
|
||||
|
||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
|
||||
@ -13,25 +13,11 @@ import (
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func (a *Agent) eventLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.eventLoop()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.handleInput(evt)
|
||||
case msg := <-a.selfInputCh:
|
||||
a.handleSelfInput(msg)
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// eventLoop 已由 scheduler.go 的 schedulerLoop 取代(M2)。
|
||||
//
|
||||
// 原实现直接在 select 里处理 inputCh/selfInputCh,没有任何可枚举的队列、
|
||||
// 无法承载优先级与抢占;现在任务先入就绪队列,由选择函数 pickTaskIndex 决定下一个。
|
||||
// 兼容性说明:M2 全部任务为 LevelBackground,因此行为等价于原先的 FIFO。
|
||||
|
||||
func (a *Agent) interceptLoop() {
|
||||
defer func() {
|
||||
@ -64,43 +50,16 @@ func (a *Agent) interceptLoop() {
|
||||
clone.Payload["interrupt_source"] = evt.Source
|
||||
clone.Payload["interrupt_channel"] = evt.OutputChannel
|
||||
|
||||
a.llmMu.Lock()
|
||||
hasActiveLLM := a.cancelLLM != nil
|
||||
if hasActiveLLM {
|
||||
a.cancelLLM()
|
||||
log.Printf("[agent] LLM request cancelled by interrupt")
|
||||
}
|
||||
a.llmMu.Unlock()
|
||||
|
||||
if hasActiveLLM {
|
||||
if a.currentOutputChannel == "_consolidation_" {
|
||||
log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
} else {
|
||||
select {
|
||||
case a.interceptCh <- clone:
|
||||
default:
|
||||
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": evt.Source,
|
||||
"interrupt_channel": evt.OutputChannel,
|
||||
})
|
||||
// 决策交给调度器:requestPreempt 总会登记中断(进中断队列或 immediate,
|
||||
// 因而不会丢),仅当它会真抢占时才告诉我“该取消可取消的步骤”。
|
||||
// 本 goroutine 不碰任何帧——只写中断队列与让位信号。
|
||||
//
|
||||
// 级别由来源声明(InjectOptions.Priority → payload["priority"]);
|
||||
// 未声明一律 L1。L4(“立即打断”)只有内核级插件能声明,
|
||||
// 外部插件即便报了 L4 也会被夹到 L3;内核自身另有 raiseKernelInterrupt。
|
||||
level := interruptLevel(evt, a.isKernelLevelSource(evt.Source))
|
||||
if a.sched.requestPreempt(clone, level) {
|
||||
a.cancelCurrentLLM()
|
||||
}
|
||||
|
||||
case <-a.ctx.Done():
|
||||
@ -109,6 +68,20 @@ func (a *Agent) interceptLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// cancelCurrentLLM 取消正在进行的 LLM 请求(若有)。
|
||||
//
|
||||
// 只有 LLM 流式步骤是可取消的;工具 RPC / ONNX / CAS 在 v1 是临界区,
|
||||
// 取消对它们无效——让位信号会等它们自然结束后的安全点(设计文档 D2)。
|
||||
func (a *Agent) cancelCurrentLLM() {
|
||||
a.llmMu.Lock()
|
||||
cancel := a.cancelLLM
|
||||
a.llmMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
log.Printf("[agent] LLM request cancelled by preemption")
|
||||
}
|
||||
}
|
||||
|
||||
// channelConsolidation 标记记忆整理类自输入:无记忆路径处理,
|
||||
// 不写入对话上下文、不向任何输出通道 emit 响应。
|
||||
const channelConsolidation = "_consolidation_"
|
||||
@ -122,22 +95,27 @@ type selfInputMsg struct {
|
||||
channel string
|
||||
}
|
||||
|
||||
func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
||||
// selfEvent 把内核自循环消息归一成输入事件。
|
||||
func selfEvent(msg selfInputMsg) *agentIO.InputEvent {
|
||||
if msg.channel == "" {
|
||||
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
|
||||
}
|
||||
a.processInput(&agentIO.InputEvent{
|
||||
return &agentIO.InputEvent{
|
||||
Source: "system",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{"content": msg.text},
|
||||
OutputChannel: msg.channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleSelfInput(msg selfInputMsg) {
|
||||
_, _ = a.runInputTask(selfEvent(msg))
|
||||
}
|
||||
|
||||
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
|
||||
switch evt.Type {
|
||||
case "text", "image", "audio":
|
||||
a.processInput(evt)
|
||||
_, _ = a.runInputTask(evt)
|
||||
|
||||
case "event":
|
||||
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
|
||||
@ -297,171 +275,51 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
||||
return blocks, alt
|
||||
}
|
||||
|
||||
// processInput 是全部模态输入的唯一主干。
|
||||
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态。
|
||||
//
|
||||
// 文本、用户上传的图/音频、插件注入的多模态块走同一条路径,因此去重、
|
||||
// no_memory、通道 Cleaner、中断语义、EventRawInput、媒体入 CAS、媒体记忆绑定
|
||||
// 对所有模态一致——不会再出现「文本路径加了功能、媒体路径没跟上」。
|
||||
func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
start := time.Now()
|
||||
|
||||
in, ok := a.resolveInput(evt)
|
||||
if !ok {
|
||||
// 为什么要单独一条路径而不是复用 emitResponse:跳过意味着“我们没有处理这条输入”,
|
||||
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
|
||||
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
|
||||
//
|
||||
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
|
||||
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
|
||||
if evt == nil || evt.ResponseCh == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 去重按文本做:webui/GUI 断线重连会重放未确认消息。
|
||||
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
|
||||
// 是同一句,拿它去重会把连发的两张图误判成重复。
|
||||
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
|
||||
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
|
||||
return
|
||||
ch := evt.OutputChannel
|
||||
if ch == "" {
|
||||
ch = evt.Source
|
||||
}
|
||||
|
||||
a.currentOutputChannel = evt.OutputChannel
|
||||
if a.currentOutputChannel == "" {
|
||||
a.currentOutputChannel = evt.Source
|
||||
payload := map[string]interface{}{
|
||||
"content": "",
|
||||
"request_id": evt.RequestID,
|
||||
"skipped": true,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
if evt.OutputChannel == "_consolidation_" {
|
||||
a.processConsolidation(evt, in.text)
|
||||
return
|
||||
}
|
||||
|
||||
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
|
||||
// 原始 data/url,也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
|
||||
//(payload 里才有 data/url);插件注入的是成品 block,取不到原始数据。
|
||||
if evt.Type == "image" || evt.Type == "audio" {
|
||||
a.pendingMedia = evt.Payload
|
||||
defer func() { a.pendingMedia = nil }()
|
||||
}
|
||||
|
||||
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
if len(in.blocks) > 0 {
|
||||
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
||||
}
|
||||
|
||||
noMemory := false
|
||||
if v, ok := evt.Payload["no_memory"].(bool); ok {
|
||||
noMemory = v
|
||||
}
|
||||
if !noMemory && a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
|
||||
noMemory = true
|
||||
}
|
||||
}
|
||||
|
||||
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
||||
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
||||
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
||||
a.mu.Lock()
|
||||
a.interruptInput = isInterrupt
|
||||
a.mu.Unlock()
|
||||
if isInterrupt {
|
||||
noMemory = true
|
||||
}
|
||||
|
||||
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
|
||||
stageCtx.Extra["input_source"] = evt.Source
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
if len(in.blocks) > 0 {
|
||||
stageCtx.Extra["media_blocks"] = in.blocks
|
||||
stageCtx.Extra["media_type"] = in.mediaType
|
||||
}
|
||||
if noMemory {
|
||||
stageCtx.NoMemory = true
|
||||
}
|
||||
a.injectSourceContext(stageCtx, evt)
|
||||
|
||||
if a.runStage(sdk.StageOnInput, stageCtx) {
|
||||
a.emitResponse(evt, *stageCtx.Response)
|
||||
return
|
||||
}
|
||||
|
||||
input := stageCtx.RawMessage
|
||||
|
||||
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
|
||||
cleanInput := input
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
||||
cleanInput = chDef.Cleaner(input)
|
||||
}
|
||||
}
|
||||
|
||||
// upload_* 字段一并转发:webui 的 EventRawInput 订阅方靠它们还原附件卡片。
|
||||
// 媒体路径此前把整个 payload 塞进 content(一个 map),订阅方按 string 断言
|
||||
// 直接失败 → 用户发的图从不出现在聊天记录里。
|
||||
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
|
||||
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
|
||||
if v, ok := evt.Payload[k]; ok {
|
||||
rawPayload[k] = v
|
||||
}
|
||||
}
|
||||
a.publishEvent(events.EventRawInput, rawPayload)
|
||||
|
||||
archived := a.pruneOnInput(evt, cleanInput)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
if !isInterrupt {
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process %s error: %v", evt.Type, err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
a.emitResponse(evt, resp)
|
||||
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
|
||||
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
|
||||
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的(后者在
|
||||
// process() 里被捕获,纯文本输入也会有)。
|
||||
turnEvt := ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: cleanInput,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
if !stageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, cleanInput, response, toolResults, toolsUsed)
|
||||
select {
|
||||
case evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
Target: evt.Source,
|
||||
Type: "text",
|
||||
Payload: payload,
|
||||
Done: true,
|
||||
OutputChannel: ch,
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
// 通道一律从**输入事件**推导(内核不持有"当前通道")。
|
||||
ch := outputChannelOf(evt)
|
||||
stageCtx := &sdk.StageContext{
|
||||
FinalText: response,
|
||||
Phase: sdk.StageBeforeOutput,
|
||||
Extra: map[string]interface{}{"output_channel": ch},
|
||||
}
|
||||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||||
response = stageCtx.FinalText
|
||||
|
||||
ch := a.currentOutputChannel
|
||||
if ch == "" {
|
||||
ch = evt.OutputChannel
|
||||
}
|
||||
if ch == "" {
|
||||
ch = evt.Source
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"content": response,
|
||||
"request_id": evt.RequestID,
|
||||
@ -496,33 +354,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||||
}
|
||||
|
||||
func (a *Agent) drainInterrupts() []string {
|
||||
var out []string
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.interceptCh:
|
||||
if evt == nil {
|
||||
continue
|
||||
}
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
source := evt.Source
|
||||
if source == "" {
|
||||
source = "unknown"
|
||||
}
|
||||
channel := evt.OutputChannel
|
||||
if channel == "" {
|
||||
channel = source
|
||||
}
|
||||
out = append(out, fmt.Sprintf("[打断消息][来源:%s][输出通道:%s] %s", source, channel, text))
|
||||
default:
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
|
||||
//
|
||||
// 默认**不裁剪**:ContextPolicy 必须在注入点(payload 的 context_policy)
|
||||
@ -539,6 +370,11 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if a.context == nil || !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
|
||||
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
|
||||
if a.isLightKernel() {
|
||||
return 0
|
||||
}
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
|
||||
@ -107,11 +107,14 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock)
|
||||
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks),用于保持块身份;
|
||||
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
|
||||
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
|
||||
if a.memory == nil {
|
||||
g := a.graphMem()
|
||||
if g == nil {
|
||||
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
|
||||
}
|
||||
if a.mediaStore == nil {
|
||||
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
|
||||
// 轻量内核(memory == nil,子只有图记忆)或没有媒体库时:只写图记忆。
|
||||
// 写目标由 a.graph 决定 —— 根落 main,子落自己的 temp。
|
||||
if a.memory == nil || a.mediaStore == nil {
|
||||
ec, rc, cErr := g.Commit(triples, sessionID, turnID)
|
||||
return ec, rc, 0, cErr
|
||||
}
|
||||
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
|
||||
|
||||
167
internal/agent/core/inputch.go
Normal file
167
internal/agent/core/inputch.go
Normal file
@ -0,0 +1,167 @@
|
||||
package core
|
||||
|
||||
// inputch 总览:**单工具多视图**。
|
||||
//
|
||||
// inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个)。
|
||||
// 父 agent 需要能看清两件事:
|
||||
// 1. 有哪些 inputch 已注册(谁注册的);
|
||||
// 2. 它们是怎么划分的(各自划给了哪个 agent、容量多少)。
|
||||
//
|
||||
// 按用户要求做成**单工具多视图**(一个 `input_channels` 工具 + `view` 参数),
|
||||
// 而不是一堆小工具 —— 视图切换比工具增殖更好用,也更省提示词预算。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
func (a *Agent) executeInputChannels(tc agentAPI.ToolCall) string {
|
||||
view, _ := tc.Arguments["view"].(string)
|
||||
view = strings.TrimSpace(view)
|
||||
if view == "" {
|
||||
view = "all"
|
||||
}
|
||||
name, _ := tc.Arguments["name"].(string)
|
||||
|
||||
all := a.io.InputChannels()
|
||||
if len(all) == 0 {
|
||||
return "没有任何已注册的 inputch。"
|
||||
}
|
||||
|
||||
switch view {
|
||||
case "all":
|
||||
return a.renderInputChannels(all, "全部已注册 inputch")
|
||||
case "mine":
|
||||
return a.renderInputChannels(a.channelRegistry().ListByOwner(string(a.id)),
|
||||
"划给本 agent("+string(a.id)+")的 inputch")
|
||||
case "unassigned":
|
||||
return a.renderInputChannels(a.channelRegistry().ListByOwner(""),
|
||||
"尚未划出的 inputch(可按需分配)")
|
||||
case "by_agent":
|
||||
return a.renderInputChannelsByAgent(all)
|
||||
case "detail":
|
||||
if name == "" {
|
||||
return "view=detail 需要 name 参数(inputch 名)"
|
||||
}
|
||||
ch, ok := a.io.LookupInputChannel(name)
|
||||
if !ok {
|
||||
return fmt.Sprintf("inputch %q 未注册", name)
|
||||
}
|
||||
return renderInputChannelDetail(ch)
|
||||
default:
|
||||
return fmt.Sprintf("未知 view=%q;可用:all | mine | unassigned | by_agent | detail", view)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) channelRegistry() *agentIO.ChannelRegistry { return a.io.ChannelRegistry() }
|
||||
|
||||
// renderInputChannels 渲染一组 inputch 的一行式概览。
|
||||
func (a *Agent) renderInputChannels(list []agentIO.InputChannel, title string) string {
|
||||
if len(list) == 0 {
|
||||
return title + ":无"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s(%d 个):", title, len(list))
|
||||
for _, ch := range list {
|
||||
fmt.Fprintf(&b, "\n - %s%s%s", ch.Name, pluginSuffix(ch), policySuffix(ch))
|
||||
fmt.Fprintf(&b, "\n 归属: %s", ownerLabel(ch.Owner))
|
||||
if ch.Capacity > 0 {
|
||||
fmt.Fprintf(&b, " | 容量: %d", ch.Capacity)
|
||||
}
|
||||
if ch.Output != "" {
|
||||
fmt.Fprintf(&b, " | 默认回程: %s", ch.Output)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderInputChannelsByAgent 按归属分组("划分情况"总览)。
|
||||
func (a *Agent) renderInputChannelsByAgent(all []agentIO.InputChannel) string {
|
||||
byOwner := map[string][]agentIO.InputChannel{}
|
||||
for _, ch := range all {
|
||||
byOwner[ch.Owner] = append(byOwner[ch.Owner], ch)
|
||||
}
|
||||
owners := make([]string, 0, len(byOwner))
|
||||
for o := range byOwner {
|
||||
owners = append(owners, o)
|
||||
}
|
||||
sort.Strings(owners)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "inputch 划分情况(共 %d 个):", len(all))
|
||||
for _, o := range owners {
|
||||
names := make([]string, 0, len(byOwner[o]))
|
||||
for _, ch := range byOwner[o] {
|
||||
names = append(names, ch.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Fprintf(&b, "\n - %s: %s", ownerLabel(o), strings.Join(names, ", "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderInputChannelDetail 渲染单个 inputch 的全部字段。
|
||||
func renderInputChannelDetail(ch agentIO.InputChannel) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "inputch: %s\n", ch.Name)
|
||||
fmt.Fprintf(&b, " 注册插件: %s\n", orDash(ch.Plugin))
|
||||
fmt.Fprintf(&b, " 归属 agent: %s\n", ownerLabel(ch.Owner))
|
||||
if ch.Capacity > 0 {
|
||||
fmt.Fprintf(&b, " 容量: %d\n", ch.Capacity)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " 容量: 内核默认\n")
|
||||
}
|
||||
fmt.Fprintf(&b, " 默认回程输出通道: %s\n", orDash(ch.Output))
|
||||
fmt.Fprintf(&b, " 记忆策略: %s\n", policyLabel(ch))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func pluginSuffix(ch agentIO.InputChannel) string {
|
||||
if ch.Plugin == "" {
|
||||
return ""
|
||||
}
|
||||
return "(插件 " + ch.Plugin + ")"
|
||||
}
|
||||
|
||||
// policySuffix 用短标记提示策略(详见 view=detail)。
|
||||
func policySuffix(ch agentIO.InputChannel) string {
|
||||
var m []string
|
||||
if ch.Def.NoMemory {
|
||||
m = append(m, "无记忆")
|
||||
}
|
||||
if ch.Def.Cleaner != nil {
|
||||
m = append(m, "清洗")
|
||||
}
|
||||
if ch.Def.ContextPolicy != "" && ch.Def.ContextPolicy != "none" {
|
||||
m = append(m, "裁剪:"+ch.Def.ContextPolicy)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " [" + strings.Join(m, "/") + "]"
|
||||
}
|
||||
|
||||
func policyLabel(ch agentIO.InputChannel) string {
|
||||
if s := policySuffix(ch); s != "" {
|
||||
return strings.Trim(s, " []")
|
||||
}
|
||||
return "默认(记入记忆、不裁剪)"
|
||||
}
|
||||
|
||||
func ownerLabel(owner string) string {
|
||||
if owner == "" {
|
||||
return "未分配(根 agent/内核默认)"
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
104
internal/agent/core/inputch_test.go
Normal file
104
internal/agent/core/inputch_test.go
Normal file
@ -0,0 +1,104 @@
|
||||
package core
|
||||
|
||||
// inputch 总览工具(单工具多视图)的验收。
|
||||
//
|
||||
// 需求:父 agent 能看到**所有已注册的 inputch**以及**它们的划分情况**。
|
||||
// 构筑方式:单工具(input_channels)+ 多视图(view=all|mine|unassigned|by_agent|detail)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
func inputChToolCall(args map[string]interface{}) agentAPI.ToolCall {
|
||||
return agentAPI.ToolCall{ID: "ic1", Name: "input_channels", Arguments: args}
|
||||
}
|
||||
|
||||
func TestInputChannelsTool_SingleToolMultipleViews(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
me := string(a.id)
|
||||
|
||||
// 同一个插件注册多个 inputch(最基本的输入路由单位);另一个插件再注册一个。
|
||||
if err := a.io.RegisterInputChannelFrom("qq", "qq", agentIO.ChannelDef{NoMemory: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.io.RegisterInputChannelFrom("qq", "qq/device-2", agentIO.ChannelDef{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.io.RegisterInputChannelFrom("sub", "sub/in", agentIO.ChannelDef{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 划分:qq 与 sub/in 归本 agent,qq/device-2 留未分配。
|
||||
if err := a.io.AssignInputChannel("qq", me, 64); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.io.AssignInputChannel("sub/in", me, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// view=all(默认):全部已注册,且带归属插件。
|
||||
all := a.executeInputChannels(inputChToolCall(nil))
|
||||
for _, want := range []string{"qq", "qq/device-2", "sub/in", "插件 qq", "插件 sub", "无记忆"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Fatalf("view=all 缺少 %q:\n%s", want, all)
|
||||
}
|
||||
}
|
||||
|
||||
// view=mine:只有划给本 agent 的。
|
||||
mine := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "mine"}))
|
||||
if !strings.Contains(mine, "qq") || !strings.Contains(mine, "sub/in") {
|
||||
t.Fatalf("view=mine 应含 qq 与 sub/in:\n%s", mine)
|
||||
}
|
||||
if strings.Contains(mine, "qq/device-2") {
|
||||
t.Fatalf("view=mine 不应含未分配的 qq/device-2:\n%s", mine)
|
||||
}
|
||||
|
||||
// view=unassigned:只有尚未划出的。
|
||||
un := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "unassigned"}))
|
||||
if !strings.Contains(un, "qq/device-2") {
|
||||
t.Fatalf("view=unassigned 应含 qq/device-2:\n%s", un)
|
||||
}
|
||||
if strings.Contains(un, "sub/in") {
|
||||
t.Fatalf("view=unassigned 不应含已划分的 sub/in:\n%s", un)
|
||||
}
|
||||
|
||||
// view=by_agent:划分情况总览(按归属分组)。
|
||||
byAgent := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "by_agent"}))
|
||||
if !strings.Contains(byAgent, me+":") {
|
||||
t.Fatalf("view=by_agent 应列出归属 %q:\n%s", me, byAgent)
|
||||
}
|
||||
if !strings.Contains(byAgent, "未分配") {
|
||||
t.Fatalf("view=by_agent 应列出未分配一组:\n%s", byAgent)
|
||||
}
|
||||
|
||||
// view=detail:单个 inputch 的全字段(容量 / 策略 / 回程 / 归属)。
|
||||
detail := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail", "name": "qq"}))
|
||||
for _, want := range []string{"inputch: qq", "注册插件: qq", "容量: 64", "无记忆", me} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Fatalf("view=detail 缺少 %q:\n%s", want, detail)
|
||||
}
|
||||
}
|
||||
if miss := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail", "name": "nope"})); !strings.Contains(miss, "未注册") {
|
||||
t.Fatalf("detail 查未注册的 inputch 应明确报错:%s", miss)
|
||||
}
|
||||
if noName := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail"})); !strings.Contains(noName, "需要 name") {
|
||||
t.Fatalf("detail 缺 name 应提示:%s", noName)
|
||||
}
|
||||
|
||||
// 未知视图必须报错并列出可用值(不要把拼错静默当成默认视图)。
|
||||
bad := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "whatever"}))
|
||||
if !strings.Contains(bad, "未知 view") || !strings.Contains(bad, "by_agent") {
|
||||
t.Fatalf("未知 view 应报错并列出可用值:%s", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// 一个 inputch 都没有时应给出明确说明,而不是空串。
|
||||
func TestInputChannelsTool_NoChannels(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
if out := a.executeInputChannels(inputChToolCall(nil)); !strings.Contains(out, "没有任何已注册") {
|
||||
t.Fatalf("空登记表应明确说明:%q", out)
|
||||
}
|
||||
}
|
||||
169
internal/agent/core/lightprofile_test.go
Normal file
169
internal/agent/core/lightprofile_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
// N2c 验收:**轻量内核 profile**。
|
||||
//
|
||||
// 设计 docs/zh/resident-subagent-design.md §16.0(窄接口 + nil 即禁用)与 §5.5。
|
||||
//
|
||||
// 轻量内核(驻留子)的记忆装配:
|
||||
// - graph = *memory.LightMemory(读 temp∪main,只写 temp)
|
||||
// - memory = nil ⇒ 既有的 `if a.memory != nil` 关卡自动禁掉**全部**整理面:
|
||||
// 记忆整理流水线(distill.go 的 archive/review/merge 循环)、记忆块与媒体桥
|
||||
// (graphmedia.go / medialoop.go)、记忆整理工具(merge/delete/purge/edit/block_merge)
|
||||
//
|
||||
// 因此这里要钉住四件事:
|
||||
// ① 子的写入只落 temp,主库不受影响;
|
||||
// ② 子的读是并集(看得到主库 + 自己的 temp);
|
||||
// ③ 整理类工具**不进子的工具表**;
|
||||
// ④ 即便被直调,整理类操作也明确报"轻量内核不支持"(纵深防御,不静默降级)。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// newLightAgent 构造一个轻量内核 agent(驻留子形态):有 LightMemory,没有整理面。
|
||||
func newLightAgent(t *testing.T, main *memory.GraphDB, tempPath string) *Agent {
|
||||
t.Helper()
|
||||
light, err := memory.NewLightMemory(main, tempPath, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New(AgentConfig{
|
||||
ID: "sub-1",
|
||||
Provider: &scriptProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
LightMemory: light, // 轻量内核:只给图记忆共同面
|
||||
})
|
||||
t.Cleanup(func() { light.Close() })
|
||||
return a
|
||||
}
|
||||
|
||||
func TestLightProfile_MemoryFaceWiring(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer main.Close()
|
||||
if _, _, err := main.Commit([]memory.Triple{
|
||||
{Subject: "主记忆实体", Relation: "属于", Object: "主库"},
|
||||
}, "sess", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := newLightAgent(t, main, filepath.Join(dir, "sub.db"))
|
||||
|
||||
// ① 整理面必须为 nil —— 这正是"nil 即禁用"的开关。
|
||||
if a.memory != nil {
|
||||
t.Fatal("轻量内核不该有整理面(a.memory 必须为 nil)")
|
||||
}
|
||||
if a.graphMem() == nil {
|
||||
t.Fatal("轻量内核必须有图记忆共同面")
|
||||
}
|
||||
|
||||
// ② 写入只落 temp:主库不得出现子才知道的实体。
|
||||
if _, _, _, err := a.commitTriplesWithMedia([]memory.Triple{
|
||||
{Subject: "子独有实体", Relation: "来自", Object: "子的temp"},
|
||||
}, "sess", 1, nil); err != nil {
|
||||
t.Fatalf("子的写入应成功(落 temp): %v", err)
|
||||
}
|
||||
mainRes, err := main.Recall([]string{"子独有实体"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range mainRes.Entities {
|
||||
if e.Name == "子独有实体" {
|
||||
t.Fatalf("子的写入不该进主库:%v", e.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 读是并集:主库的实体与 temp 的实体都要看得到。
|
||||
got := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
ID: "c1", Name: "memory_recall",
|
||||
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
|
||||
})
|
||||
if !strings.Contains(got, "主记忆实体") {
|
||||
t.Fatalf("子应看得到主记忆:%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "子独有实体") {
|
||||
t.Fatalf("子应看得到自己的 temp:%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer main.Close()
|
||||
|
||||
a := newLightAgent(t, main, filepath.Join(dir, "sub.db"))
|
||||
|
||||
// ① 整理类工具不进子的工具表(不是进去再报不可用)。
|
||||
names := toolNames(a)
|
||||
for _, banned := range []string{
|
||||
"memory_merge", "memory_delete_entity", "memory_block_merge",
|
||||
"memory_purge", "memory_edit",
|
||||
} {
|
||||
if hasTool(names, banned) {
|
||||
t.Fatalf("轻量内核不该声明整理类工具 %s:%v", banned, names)
|
||||
}
|
||||
}
|
||||
// 对照:共同面/无关能力照常在。
|
||||
if !hasTool(names, "input_channels") {
|
||||
t.Fatalf("轻量内核仍应有共同面工具:%v", names)
|
||||
}
|
||||
|
||||
// ② 纵深防御:即便被直调,整理类操作也必须明确报"轻量内核不支持"。
|
||||
for _, tool := range []string{
|
||||
"memory_merge", "memory_delete_entity", "memory_block_merge",
|
||||
"memory_purge", "memory_edit", "memory_introspect",
|
||||
} {
|
||||
got := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
ID: "x", Name: tool,
|
||||
Arguments: map[string]interface{}{
|
||||
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
if !strings.Contains(got, "轻量内核") {
|
||||
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对照:完整内核(根 agent)仍有整理面与整理工具。
|
||||
func TestFullProfile_KeepsOrganizeFace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer main.Close()
|
||||
|
||||
a := New(AgentConfig{
|
||||
ID: "root",
|
||||
Provider: &scriptProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Memory: main,
|
||||
})
|
||||
if a.memory == nil {
|
||||
t.Fatal("根 agent 必须有整理面")
|
||||
}
|
||||
if a.graphMem() == nil {
|
||||
t.Fatal("根 agent 必须有图记忆共同面")
|
||||
}
|
||||
names := toolNames(a)
|
||||
if !hasTool(names, "memory_merge") {
|
||||
t.Fatalf("根 agent 应保留整理类工具:%v", names)
|
||||
}
|
||||
}
|
||||
@ -138,7 +138,7 @@ func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
|
||||
//
|
||||
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
|
||||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
||||
// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
|
||||
func (a *Agent) stageMediaDigests(digests ...string) {
|
||||
if len(digests) == 0 {
|
||||
return
|
||||
@ -197,7 +197,7 @@ func mediaLabel(it *media.Item) string {
|
||||
//
|
||||
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
|
||||
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
|
||||
// a.mu 串行化 Append,不存在同纳秒两条。
|
||||
// schedulerLoop 单 goroutine 串行 Append,不存在同纳秒两条。
|
||||
func newEventID() string {
|
||||
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
56
internal/agent/core/memoryface.go
Normal file
56
internal/agent/core/memoryface.go
Normal file
@ -0,0 +1,56 @@
|
||||
package core
|
||||
|
||||
// 图记忆的**共同面**(设计 docs/zh/resident-subagent-design.md §16.0)。
|
||||
//
|
||||
// 只有两个方法 —— 因为按调用方实测分类后,`Agent.memory` 的 42 处使用里,
|
||||
// 真正"根与子都要"的只有:
|
||||
//
|
||||
// Recall 读(上下文检索 / memory_recall)
|
||||
// Commit 写(自动写入路径的图部分 / memory_commit)
|
||||
//
|
||||
// 其余 40 处全是**主 agent 整理记忆**与**记忆整理流水线**:
|
||||
// - 记忆整理流水线:distill.go 的 archive/review/merge 循环
|
||||
// - 记忆块 + 媒体桥:graphmedia.go / medialoop.go
|
||||
// - 记忆整理工具:memory_merge / memory_delete_entity / memory_block_merge /
|
||||
// memory_purge / memory_edit / memory_introspect
|
||||
//
|
||||
// 所以驻留子的轻量内核**不该有那些代码路径**:它用 `*memory.LightMemory` 接上 `graph`,
|
||||
// 而 `a.memory`(整理面)保持 nil —— 既有的 22 处 `if a.memory != nil` 关卡
|
||||
// 会自动把整理面全部禁掉,不需要写"每个方法都返回错误"的受限包装。
|
||||
|
||||
import "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
|
||||
// isLightKernel 报告本 agent 是不是**轻量内核**(驻留子)。
|
||||
//
|
||||
// 轻量内核 = 传统上下文 + 图记忆(作用域化):它**没有**动态上下文能力
|
||||
// (预算裁剪 / 按相关度裁剪并向 doc 记忆归档),那些是父 agent 专属的。
|
||||
func (a *Agent) isLightKernel() bool { return a != nil && a.parentID != "" }
|
||||
|
||||
// contextTokenBudget 返回拼装时间线时可用的 token 预算。
|
||||
//
|
||||
// 完整内核:用动态上下文算出来的 ContextTokens(按相关度/预算**策略性**裁时间线)
|
||||
// 轻量内核:**用整个窗口** —— 传统上下文只受"模型能收多少"这个**硬上限**约束,
|
||||
// 不做任何策略性裁剪(不按相关度挑、不向 doc 记忆归档);
|
||||
// 而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent
|
||||
// 决策(压缩/回收/销毁)—— 丢事件的决定权在父,不在内核。
|
||||
func (a *Agent) contextTokenBudget(b TokenBudget) int {
|
||||
if a.isLightKernel() {
|
||||
if b.MaxContext > 0 {
|
||||
return b.MaxContext
|
||||
}
|
||||
return defaultMaxContextTokens
|
||||
}
|
||||
return b.ContextTokens
|
||||
}
|
||||
|
||||
// GraphMemory 是任意 agent 都能用的图记忆面。
|
||||
//
|
||||
// 实现者:
|
||||
// - 根 agent:直接就是 *memory.GraphDB
|
||||
// - 驻留子:*memory.LightMemory(读 temp∪main,只写 temp)
|
||||
type GraphMemory interface {
|
||||
// Recall 按关键词/种子实体召回(子的实现是两空间并集)。
|
||||
Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
|
||||
// Commit 写入三元组(子的实现只落自己的 temp 空间)。
|
||||
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
|
||||
}
|
||||
@ -16,6 +16,11 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
if channel == "" || payload == "" || rawType == "" {
|
||||
return "工具名称格式: output_send__{channel},payload 和 type 不能为空"
|
||||
}
|
||||
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
|
||||
if !a.IsOutputAllowed(channel) {
|
||||
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
|
||||
}
|
||||
|
||||
meta, _ := tc.Arguments["meta"].(string)
|
||||
|
||||
caps := a.io.GetChannelCapabilities(channel)
|
||||
@ -146,7 +151,14 @@ func (a *Agent) executeOutputListChannels() string {
|
||||
if ch.OutputCaps == 0 {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description))
|
||||
if !a.IsOutputAllowed(ch.Name) {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description)
|
||||
if t, ok := a.ResolveOutputTarget(ch.Name); ok {
|
||||
line += fmt.Sprintf("(目标: %s / inputch %s)", orDash(t.AgentID), orDash(t.InputCh))
|
||||
}
|
||||
parts = append(parts, line)
|
||||
for _, t := range ch.Tools {
|
||||
parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
|
||||
}
|
||||
|
||||
138
internal/agent/core/output_grant_test.go
Normal file
138
internal/agent/core/output_grant_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
package core
|
||||
|
||||
// N1b:输出通道授权集合 + 输出通道 → 目标 agent 的 inputch 解析。
|
||||
//
|
||||
// 设计依据 docs/zh/resident-subagent-design.md §4.4(通道分配:不对称)与 R2
|
||||
// (插件与工具由父授权,**默认完整授权**)。
|
||||
//
|
||||
// 三个过滤点必须一致,否则会出现"列表里看不到、但按名字还能调"的裂缝:
|
||||
// ① 工具表(不为未授权的通道生成 output_send__X)
|
||||
// ② 列表工具(output_list_channels 只列授权的)
|
||||
// ③ 调用点(凭名字直调也必须被拒 —— 纵深防御)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// registerFakeOutput 注册一个假的输出通道(device 通道)。
|
||||
func registerFakeOutput(t *testing.T, a *Agent, name string) {
|
||||
t.Helper()
|
||||
if err := a.io.RegisterDevice(&mockOutputDevice{name: name, caps: agentIO.CapText}); err != nil {
|
||||
t.Fatalf("注册测试通道 %s 失败: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func toolNames(a *Agent) []string {
|
||||
var names []string
|
||||
for _, t := range a.buildToolDefs() {
|
||||
m, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, _ := m["function"].(map[string]interface{})
|
||||
if n, _ := fn["name"].(string); n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func hasTool(names []string, want string) bool {
|
||||
for _, n := range names {
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 默认(未配置白名单)= 完整授权:所有输出通道都能用。
|
||||
func TestOutputGrant_DefaultIsFull(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
registerFakeOutput(t, a, "qq")
|
||||
registerFakeOutput(t, a, "webui")
|
||||
|
||||
if !a.IsOutputAllowed("qq") || !a.IsOutputAllowed("webui") {
|
||||
t.Fatal("默认应为完整授权")
|
||||
}
|
||||
names := toolNames(a)
|
||||
if !hasTool(names, "output_send__qq") || !hasTool(names, "output_send__webui") {
|
||||
t.Fatalf("默认完整授权下应生成全部输出门,实际 %v", names)
|
||||
}
|
||||
if out := a.executeOutputListChannels(); !strings.Contains(out, "qq") || !strings.Contains(out, "webui") {
|
||||
t.Fatalf("默认完整授权下列表应含全部通道:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单收窄:三个过滤点必须一致。
|
||||
func TestOutputGrant_NarrowedWhitelist(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.allowedOutputs = []string{"webui"} // 模拟父创建子时收窄
|
||||
registerFakeOutput(t, a, "qq")
|
||||
registerFakeOutput(t, a, "webui")
|
||||
|
||||
if a.IsOutputAllowed("qq") {
|
||||
t.Fatal("白名单外的通道不应被授权")
|
||||
}
|
||||
if !a.IsOutputAllowed("webui") {
|
||||
t.Fatal("白名单内的通道应被授权")
|
||||
}
|
||||
|
||||
// ① 工具表
|
||||
names := toolNames(a)
|
||||
if hasTool(names, "output_send__qq") {
|
||||
t.Fatalf("未授权的通道不该生成输出门工具:%v", names)
|
||||
}
|
||||
if !hasTool(names, "output_send__webui") {
|
||||
t.Fatalf("已授权的通道应生成输出门工具:%v", names)
|
||||
}
|
||||
|
||||
// ② 列表工具
|
||||
out := a.executeOutputListChannels()
|
||||
if strings.Contains(out, "qq") {
|
||||
t.Fatalf("列表不应含未授权通道:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "webui") {
|
||||
t.Fatalf("列表应含已授权通道:\n%s", out)
|
||||
}
|
||||
|
||||
// ③ 调用点(凭名字直调)
|
||||
got := a.executeOutputSendTool(agentAPI.ToolCall{
|
||||
ID: "c1", Name: "output_send__qq",
|
||||
Arguments: map[string]interface{}{"payload": "hi", "type": "text"},
|
||||
})
|
||||
if !strings.Contains(got, "未授权") {
|
||||
t.Fatalf("未授权的输出门必须被拒,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 输出通道 → 目标 agent 的 inputch 的解析("输出可寻址到具体 agent")。
|
||||
func TestOutputTarget_Resolution(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
reg := a.io.ChannelRegistry()
|
||||
|
||||
if err := reg.BindOutputTarget("to-child-1", "child-1", "sub/in"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tgt, ok := a.ResolveOutputTarget("to-child-1")
|
||||
if !ok || tgt.AgentID != "child-1" || tgt.InputCh != "sub/in" {
|
||||
t.Fatalf("解析结果=%+v ok=%v", tgt, ok)
|
||||
}
|
||||
// 未登记的输出通道由传输层处理(如 qq/webui 这类 device 通道)。
|
||||
if _, ok := a.ResolveOutputTarget("qq"); ok {
|
||||
t.Fatal("未登记目标解析的输出通道不应解析出 agent")
|
||||
}
|
||||
if err := reg.BindOutputTarget("", "x", "y"); err == nil {
|
||||
t.Fatal("空输出通道名应报错")
|
||||
}
|
||||
|
||||
// 列表工具在已登记时带出目标,便于模型知道"这条通道发给谁"。
|
||||
registerFakeOutput(t, a, "to-child-1")
|
||||
if out := a.executeOutputListChannels(); !strings.Contains(out, "child-1") {
|
||||
t.Fatalf("已登记目标的输出通道应在列表里标出目标:\n%s", out)
|
||||
}
|
||||
}
|
||||
54
internal/agent/core/persona.go
Normal file
54
internal/agent/core/persona.go
Normal file
@ -0,0 +1,54 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// PersonaStore 是人格设定的读写面。
|
||||
//
|
||||
// 首启门禁(buildSystemPrompt)与 persona_set 工具都通过它工作,实现在 cmd/homed:
|
||||
// 读写配置项 core.agent.personal_prompt 与一次性标记 core.internal.persona_initialized
|
||||
// (落库逻辑与 WebUI 向导共用 internal/config 的实现)。
|
||||
//
|
||||
// 为什么放在内核而不是某个通道插件:人格是**任何通道都要问一次**的事。
|
||||
// 系统提示词每轮重建,门禁放在这里,WebUI / QQ / CLI / ACP / 邮件等全部通道自动覆盖。
|
||||
type PersonaStore interface {
|
||||
// PersonaInitialized 报告人格是否已确认(向导或工具已问过)。
|
||||
PersonaInitialized() bool
|
||||
// SetPersona 落库人格并打一次性标记,返回是否需要重启才生效。
|
||||
SetPersona(mode, content string) (restartRequired bool, err error)
|
||||
}
|
||||
|
||||
// executePersonaTool 落地首启人格设定。
|
||||
//
|
||||
// 成功即打一次性标记 → 之后 buildSystemPrompt 不再要求模型询问人格。
|
||||
// custom 模式返回「需重启生效」:人格在 homed 启动时载入。
|
||||
func (a *Agent) executePersonaTool(tc agentAPI.ToolCall) string {
|
||||
if a.personaStore == nil {
|
||||
return "人格设定不可用:内核未接入配置"
|
||||
}
|
||||
mode, _ := tc.Arguments["mode"].(string)
|
||||
content, _ := tc.Arguments["content"].(string)
|
||||
mode = strings.TrimSpace(mode)
|
||||
restart, err := a.personaStore.SetPersona(mode, content)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("人格设定失败:%v", err)
|
||||
}
|
||||
switch mode {
|
||||
case "custom":
|
||||
msg := "已保存自定义人格"
|
||||
if restart {
|
||||
msg += ";**重启 homed 后生效**(人格在启动时载入)"
|
||||
}
|
||||
return msg
|
||||
case "default":
|
||||
return "已确认使用默认人格"
|
||||
case "later":
|
||||
return "已记为「以后再说」,继续使用默认人格"
|
||||
default:
|
||||
return "已保存人格设定"
|
||||
}
|
||||
}
|
||||
112
internal/agent/core/persona_test.go
Normal file
112
internal/agent/core/persona_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// newTestAgent 造一个最小可用的 Agent(buildToolDefs 要求 io 非 nil)。
|
||||
func newTestAgent(st PersonaStore) *Agent {
|
||||
return &Agent{io: agentIO.NewIOManager(), personaStore: st}
|
||||
}
|
||||
|
||||
// fakePersonaStore 记录调用并可控地报告「是否已确认」。
|
||||
type fakePersonaStore struct {
|
||||
initialized bool
|
||||
mode string
|
||||
content string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakePersonaStore) PersonaInitialized() bool { return f.initialized }
|
||||
|
||||
func (f *fakePersonaStore) SetPersona(mode, content string) (bool, error) {
|
||||
f.calls++
|
||||
f.mode, f.content = mode, content
|
||||
f.initialized = true
|
||||
return mode == "custom", nil
|
||||
}
|
||||
|
||||
// 首启门禁:人格未确认时,**任何通道**的系统提示词都必须带上「去问用户」的指令;
|
||||
// 确认后必须消失(否则会每轮反复追问)。
|
||||
func TestPersonaOnboardingGateInSystemPrompt(t *testing.T) {
|
||||
st := &fakePersonaStore{}
|
||||
a := newTestAgent(st)
|
||||
|
||||
p := a.buildSystemPrompt("", "你好")
|
||||
if !strings.Contains(p, "首启人格设定") || !strings.Contains(p, "persona_set") {
|
||||
t.Fatalf("未确认人格时提示词应要求模型询问并调用 persona_set,实际缺少该段")
|
||||
}
|
||||
|
||||
// 模型落地后(标记置位)不再出现
|
||||
if out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
|
||||
Arguments: map[string]interface{}{"mode": "default"}}); !strings.Contains(out, "默认人格") {
|
||||
t.Fatalf("persona_set(default) 回执不对: %s", out)
|
||||
}
|
||||
if !st.initialized {
|
||||
t.Fatal("落库后应置位标记")
|
||||
}
|
||||
if p2 := a.buildSystemPrompt("", "你好"); strings.Contains(p2, "首启人格设定") {
|
||||
t.Fatal("人格已确认后不应再要求询问")
|
||||
}
|
||||
|
||||
// 未接入配置(personaStore 为 nil)时,门禁与工具都必须静默关闭
|
||||
b := newTestAgent(nil)
|
||||
if pb := b.buildSystemPrompt("", "你好"); strings.Contains(pb, "首启人格设定") {
|
||||
t.Fatal("未接入配置时不应出现首启门禁")
|
||||
}
|
||||
if out := b.executePersonaTool(agentAPI.ToolCall{Name: "persona_set"}); !strings.Contains(out, "不可用") {
|
||||
t.Fatalf("未接入配置时工具应回明确错误,实际: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// persona_set 的三选一语义与回执。
|
||||
func TestPersonaSetToolModes(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode, content, want string
|
||||
}{
|
||||
{"custom", "你是测试人格", "重启"},
|
||||
{"default", "", "默认人格"},
|
||||
{"later", "", "以后再说"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st := &fakePersonaStore{}
|
||||
a := newTestAgent(st)
|
||||
out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
|
||||
Arguments: map[string]interface{}{"mode": c.mode, "content": c.content}})
|
||||
if !strings.Contains(out, c.want) {
|
||||
t.Errorf("mode=%s 回执应含 %q,实际: %s", c.mode, c.want, out)
|
||||
}
|
||||
if st.calls != 1 || st.mode != c.mode || st.content != c.content {
|
||||
t.Errorf("mode=%s 落库参数不对: calls=%d mode=%s content=%q", c.mode, st.calls, st.mode, st.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具 schema 必须在 catalog 里出现(模型才可能调用)。
|
||||
func TestPersonaSetToolDefPresent(t *testing.T) {
|
||||
a := newTestAgent(&fakePersonaStore{})
|
||||
found := false
|
||||
for _, td := range a.buildToolDefs() {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("buildToolDefs 未包含 persona_set")
|
||||
}
|
||||
// 未接入配置时不应暴露该工具
|
||||
b := newTestAgent(nil)
|
||||
for _, td := range b.buildToolDefs() {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
|
||||
t.Fatal("未接入配置时不应暴露 persona_set")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,12 +7,10 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// continuationPlaceholder 是工具轮之后补的 user 占位内容。
|
||||
@ -99,420 +97,6 @@ func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message {
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.provider == nil {
|
||||
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
|
||||
}
|
||||
|
||||
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||
|
||||
memContext := a.buildMemoryContext(input, budget.MemoryTokens)
|
||||
sysPrompt := a.buildSystemPrompt(memContext, input)
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
msgs := a.buildMessages(sysPrompt, input, budget.ContextTokens)
|
||||
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
|
||||
if a.interruptInput {
|
||||
last := msgs[len(msgs)-1]
|
||||
last.Role = "system"
|
||||
last.Content = "[中断消息] " + last.Content
|
||||
msgs[len(msgs)-1] = last
|
||||
a.interruptInput = false
|
||||
}
|
||||
if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
|
||||
if len(msgs) > 0 {
|
||||
msgs[len(msgs)-1].Blocks = blocks
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[agent] tool call loop start, max_ctx=%d target=%d fixed=%d mem=%d ctx=%d %d tools, %d events, personality=%t, docs=%d",
|
||||
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
|
||||
len(tools), a.context.Len(),
|
||||
a.personality != nil && a.personality.Content != "",
|
||||
a.docStoreSize())
|
||||
|
||||
if a.runStage(sdk.StagePreAction, stageCtx) {
|
||||
return *stageCtx.Response, toolsUsed, toolResults, nil
|
||||
}
|
||||
if len(stageCtx.ContextMsgs) > 0 {
|
||||
for _, m := range stageCtx.ContextMsgs {
|
||||
role, _ := m["role"].(string)
|
||||
content, _ := m["content"].(string)
|
||||
if role != "" {
|
||||
msgs = append(msgs, agentAPI.Message{Role: role, Content: content})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lastBatchReplyOnly 记录上一批工具调用是否全部是输出通道发送。
|
||||
lastBatchReplyOnly := false
|
||||
|
||||
for turn := 0; ; turn++ {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "system",
|
||||
Content: "[中断消息] " + interrupt,
|
||||
})
|
||||
}
|
||||
|
||||
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
// 注意:仅当尾部确为工具轮产物(assistant/tool)时才补位;首轮 system 上下文结尾不补,
|
||||
// 否则会错误覆盖实际用户输入(如 injectSourceContext 追加的 system 说明)。
|
||||
//
|
||||
// 补位前先移除前面轮次插入的同类占位,保证占位**不随轮次线性累积**——
|
||||
// 占位是核心插的传输层附加物,不是用户发言,不该在 prompt 里叠成 N 条。
|
||||
//
|
||||
// 文案分情况:上一批全是 output_send__* 时不能说“继续”,详见
|
||||
// replyDeliveredPlaceholder 的说明。
|
||||
msgs = dropContinuationPlaceholders(msgs)
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: continuationFor(lastBatchReplyOnly),
|
||||
})
|
||||
}
|
||||
|
||||
req := &agentAPI.CompletionRequest{
|
||||
Messages: msgs,
|
||||
MaxTokens: 4096,
|
||||
Tools: tools,
|
||||
ToolChoice: "auto",
|
||||
DisableThinking: !a.thinkingEnabled,
|
||||
}
|
||||
|
||||
var providers []agentAPI.Provider
|
||||
if a.providerManager != nil {
|
||||
// 精确模型名走 byModel 路由;AUTO/空走优先级链
|
||||
var allProviders []agentAPI.Provider
|
||||
if req.Model != "" && !strings.EqualFold(req.Model, "AUTO") {
|
||||
allProviders = a.providerManager.ResolveForModel(req.Model)
|
||||
} else {
|
||||
allProviders = a.providerManager.OrderedProviders()
|
||||
}
|
||||
providers = make([]agentAPI.Provider, 0, len(allProviders))
|
||||
for _, p := range allProviders {
|
||||
if a.providerManager.IsAvailable(p.Name()) {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
providers = []agentAPI.Provider{a.provider}
|
||||
}
|
||||
var resp *agentAPI.CompletionResponse
|
||||
var llmErr error
|
||||
|
||||
for pi, fbProvider := range providers {
|
||||
if pi > 0 {
|
||||
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
|
||||
fbProvider.Name(), pi, len(providers)-1)
|
||||
}
|
||||
|
||||
// 同源瞬时错误重试:网关瞬断(502/503/504/429/网络抖动)通常秒级恢复,
|
||||
// 直接跳下一个 provider(或直接报错)会丢掉本可成功的请求。
|
||||
// 凭证错误(401/403)与用户中断不重试。
|
||||
const maxAttempts = 2
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("[agent] provider %q transient failure, retry %d/%d in 2s: %v",
|
||||
fbProvider.Name(), attempt, maxAttempts, llmErr)
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
case <-a.ctx.Done():
|
||||
llmErr = a.ctx.Err()
|
||||
}
|
||||
if llmErr == nil || errors.Is(llmErr, context.Canceled) || errors.Is(llmErr, context.DeadlineExceeded) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fCtx, fCancel := context.WithCancel(a.ctx)
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = fCancel
|
||||
a.llmMu.Unlock()
|
||||
|
||||
resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a)
|
||||
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = nil
|
||||
a.llmMu.Unlock()
|
||||
fCancel()
|
||||
|
||||
if llmErr == nil {
|
||||
a.providerManager.ResetAvailability(fbProvider.Name())
|
||||
if fbProvider != a.provider {
|
||||
a.provider = fbProvider
|
||||
log.Printf("[agent] switched active provider to %q after fallback",
|
||||
fbProvider.Name())
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 用户中断:立即终止,不重试也不换 provider
|
||||
if errors.Is(llmErr, context.Canceled) {
|
||||
break
|
||||
}
|
||||
// 凭证错误:重试无意义,跳出重试循环进入 provider 标记/切换
|
||||
var pe *agentAPI.ProviderError
|
||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||
break
|
||||
}
|
||||
// 其余错误(含 5xx/429/网络):还有重试机会则继续,否则跳出
|
||||
}
|
||||
|
||||
if llmErr == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(llmErr, context.Canceled) {
|
||||
break
|
||||
}
|
||||
var pe *agentAPI.ProviderError
|
||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
||||
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
||||
} else {
|
||||
a.providerManager.MarkUnavailable(fbProvider.Name())
|
||||
}
|
||||
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
||||
}
|
||||
|
||||
if llmErr != nil {
|
||||
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
|
||||
if a.currentOutputChannel == "_consolidation_" {
|
||||
return "", toolsUsed, toolResults, fmt.Errorf("interrupted by user input")
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "", toolsUsed, toolResults, fmt.Errorf("all %d providers failed, last error: %w",
|
||||
len(providers), llmErr)
|
||||
}
|
||||
|
||||
stageCtx.LLMText = resp.Content
|
||||
stageCtx.ReasoningContent = resp.ReasoningContent
|
||||
stageCtx.TokenUsage = map[string]int{
|
||||
"prompt_tokens": resp.TokenUsage.Prompt,
|
||||
"completion_tokens": resp.TokenUsage.Completion,
|
||||
"total_tokens": resp.TokenUsage.Total,
|
||||
}
|
||||
stageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
|
||||
for i := range stageCtx.ToolCalls {
|
||||
if stageCtx.ToolCalls[i].Plugin == "" {
|
||||
stageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(stageCtx.ToolCalls[i].Name)
|
||||
}
|
||||
}
|
||||
if a.runStage(sdk.StagePostAction, stageCtx) {
|
||||
return *stageCtx.Response, toolsUsed, toolResults, nil
|
||||
}
|
||||
resp.Content = stageCtx.LLMText
|
||||
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
|
||||
|
||||
chainPayload := map[string]interface{}{
|
||||
"content": resp.Content,
|
||||
"reasoning": resp.ReasoningContent,
|
||||
"tool_calls": resp.ToolCalls,
|
||||
"phase": "intermediate",
|
||||
"turn": turn,
|
||||
}
|
||||
if resp.TokenUsage.Total > 0 {
|
||||
chainPayload["usage"] = map[string]int{
|
||||
"prompt": resp.TokenUsage.Prompt,
|
||||
"completion": resp.TokenUsage.Completion,
|
||||
"total": resp.TokenUsage.Total,
|
||||
}
|
||||
}
|
||||
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
||||
|
||||
if resp.ReasoningContent != "" {
|
||||
a.publishEvent(events.EventReasoning, map[string]interface{}{
|
||||
"content": resp.ReasoningContent,
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
|
||||
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
|
||||
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break,
|
||||
// 放在循环里统计会漏。
|
||||
replyOnly := true
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if !isOutputDeliveryTool(tc.Name) {
|
||||
replyOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
contentOnce := true
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if len(a.interceptCh) > 0 {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
||||
}
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": a.resolveToolPlugin(tc.Name),
|
||||
"args": tc.Arguments,
|
||||
"status": "interrupted",
|
||||
"reason": "user interrupt before execution",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
toolsUsed = append(toolsUsed, tc.Name)
|
||||
pluginName := a.resolveToolPlugin(tc.Name)
|
||||
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
||||
if tc.RawArguments != "" {
|
||||
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
|
||||
}
|
||||
|
||||
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
||||
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
||||
stageCtx.ToolResults = nil
|
||||
if a.runStage(sdk.StageBeforeToolcall, stageCtx) {
|
||||
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "denied",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
continue
|
||||
}
|
||||
tc.Arguments = stageCtx.ToolCalls[0].Arguments
|
||||
|
||||
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
|
||||
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
|
||||
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
continue
|
||||
}
|
||||
|
||||
result := a.executeToolCall(tc)
|
||||
toolResults = append(toolResults, ToolResultItem{Name: tc.Name, Output: result})
|
||||
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
|
||||
|
||||
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Plugin: pluginName, Success: true, Result: result}}
|
||||
a.runStage(sdk.StageAfterToolcall, stageCtx)
|
||||
if len(stageCtx.ToolResults) > 0 {
|
||||
if r, ok := stageCtx.ToolResults[0].Result.(string); ok {
|
||||
result = r
|
||||
}
|
||||
}
|
||||
// ContextPolicy: prune 工具调用后执行上下文裁剪(§13.8)
|
||||
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
|
||||
if a.context != nil {
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
// 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON
|
||||
// 包装)会把相关性打分带偏,裁掉本该保留的事件。
|
||||
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
|
||||
}
|
||||
}
|
||||
|
||||
msgContent := ""
|
||||
if contentOnce {
|
||||
msgContent = resp.Content
|
||||
contentOnce = false
|
||||
}
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: msgContent, ReasoningContent: resp.ReasoningContent, ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
|
||||
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
|
||||
//
|
||||
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
|
||||
// 这也是插件文案一直在说的「注入后续对话」。
|
||||
// 为何不能挂 tool message:同一张图、同一模型、三轮实测——
|
||||
// 图在 user message → 3/3 读到
|
||||
// 图在 tool message → 0/3(模型答「没能读到这张图」)
|
||||
// tool 纯文本 + 后接 user → 3/3 读到
|
||||
// tool message 那轮 prompt_tokens 反而更高(7967 vs 7089),base64 确实
|
||||
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
|
||||
//
|
||||
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
|
||||
// 返回 200,模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
|
||||
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
var mediaMsg *agentAPI.Message
|
||||
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
|
||||
var blocks []agentAPI.ContentBlock
|
||||
for _, b := range rawBlocks {
|
||||
if cb, ok := b.(pubsdk.ContentBlock); ok {
|
||||
// 跨包类型拷贝(pubsdk.ContentBlock → agentAPI.ContentBlock)
|
||||
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
|
||||
if cb.ImageURL != nil {
|
||||
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
|
||||
}
|
||||
if cb.AudioURL != nil {
|
||||
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
||||
// 不存的后果是 ToolResultItem.Output 只剩那句
|
||||
// "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。
|
||||
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
|
||||
|
||||
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
||||
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
|
||||
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
|
||||
mediaBlocks := append([]agentAPI.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
|
||||
}}, native...)
|
||||
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
|
||||
} else if fallbackText != "" {
|
||||
// 回退链已把媒体转写成文字:并进 tool message 的纯文本 content,
|
||||
// 不再另起消息(文字在 tool message 里本来就能被读到)。
|
||||
toolMsg.Content = result + "\n\n" + fallbackText
|
||||
result = toolMsg.Content
|
||||
if len(toolResults) > 0 {
|
||||
toolResults[len(toolResults)-1].Output = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
msgs = append(msgs, toolMsg)
|
||||
if mediaMsg != nil {
|
||||
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
|
||||
msgs = append(msgs, *mediaMsg)
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
"channel": a.currentOutputChannel,
|
||||
})
|
||||
|
||||
if len(a.interceptCh) > 0 {
|
||||
for _, interrupt := range a.drainInterrupts() {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 供下一轮顶部选择补位文案。
|
||||
lastBatchReplyOnly = replyOnly
|
||||
}
|
||||
}
|
||||
|
||||
// chatStreamWithFallback 优先流式调用 provider,失败时回退非流式 Chat()。
|
||||
//
|
||||
// 流式路径:ChatStream 拿到 chunk channel,逐块累积 content/reasoning_content,
|
||||
@ -525,14 +109,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
//
|
||||
// 超时收益:首包 ~1-3s 到达即建立活性,后续只要 token 在流动就不会触发
|
||||
// 空闲超时;总生成时长不再受限於 180s 整体超时。
|
||||
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent) (*agentAPI.CompletionResponse, error) {
|
||||
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
|
||||
ch, err := p.ChatStream(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("[agent] stream connect failed (%v), falling back to non-stream chat", err)
|
||||
return p.Chat(ctx, req)
|
||||
}
|
||||
|
||||
resp, accErr := accumulateStream(ctx, ch, a)
|
||||
resp, accErr := accumulateStream(ctx, ch, a, channel)
|
||||
|
||||
// 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致:
|
||||
// 被 cancel 时丢弃已收内容返回 err),让 process() 的 continue 分支
|
||||
@ -543,7 +127,7 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent
|
||||
if a != nil {
|
||||
a.publishEvent(events.EventContentDelta, map[string]interface{}{
|
||||
"content": "",
|
||||
"channel": a.currentOutputChannel,
|
||||
"channel": channel,
|
||||
"reset": true,
|
||||
})
|
||||
}
|
||||
@ -573,7 +157,7 @@ type toolCallAcc struct {
|
||||
|
||||
// accumulateStream 消费 chunk channel,累积为完整 CompletionResponse,
|
||||
// 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。
|
||||
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent) (*agentAPI.CompletionResponse, error) {
|
||||
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
|
||||
resp := &agentAPI.CompletionResponse{
|
||||
ToolCalls: make([]agentAPI.ToolCall, 0),
|
||||
}
|
||||
@ -625,7 +209,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
|
||||
if a != nil {
|
||||
a.publishEvent(events.EventReasoningDelta, map[string]interface{}{
|
||||
"content": ck.ReasoningContent,
|
||||
"channel": a.currentOutputChannel,
|
||||
"channel": channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -634,7 +218,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
|
||||
if a != nil {
|
||||
a.publishEvent(events.EventContentDelta, map[string]interface{}{
|
||||
"content": ck.Content,
|
||||
"channel": a.currentOutputChannel,
|
||||
"channel": channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -747,11 +331,13 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
|
||||
include := 0
|
||||
for i := len(events) - 1; i >= 0; i-- {
|
||||
e := events[i]
|
||||
est := len(e.Source) + len(e.Input) + 40
|
||||
// ❗单位必须与 EstimateTokens 一致(rune×2)。这里曾用 `len()`(**字节**)再 ×2:
|
||||
// CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break,
|
||||
// 把更早的事件整段丢掉(实测:2384 字的中文事件被估成 14398 token > 8192)。
|
||||
estTokens := EstimateTokens(e.Source) + EstimateTokens(e.Input) + 40
|
||||
if e.Response != "" {
|
||||
est += 120
|
||||
estTokens += 120
|
||||
}
|
||||
estTokens := est * 2
|
||||
if remaining-estTokens < 0 && include > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
515
internal/agent/core/resident.go
Normal file
515
internal/agent/core/resident.go
Normal file
@ -0,0 +1,515 @@
|
||||
package core
|
||||
|
||||
// 驻留式子 agent 的**生命周期与控制面**(设计 docs/zh/resident-subagent-design.md §7/§9/§10)。
|
||||
//
|
||||
// 父 ──创建/发送消息/查看/压缩/回收/销毁──► 驻留子
|
||||
// 子 ──主动消息(L3) / contextfull(L4)──► 父
|
||||
//
|
||||
// 层级关系:
|
||||
// - 父持**登记表**(residents),它是查看·发送·压缩·回收·销毁的寻址依据;
|
||||
// - 父 `Stop()` ⇒ 销毁全部子(**不留孤儿**);
|
||||
// - 子的 `KernelSource` = 父;父则把子的 contextfull 当**内核级事件**(raiseKernelInterrupt)上报给自己;
|
||||
// - 子持有自己的 **inputch 处理表**(父 pull,不打断子)。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
// InputchRecord 是 inputch 处理表的一条记录(子持有,父 pull)。
|
||||
type InputchRecord struct {
|
||||
InputCh string `json:"inputch"`
|
||||
At time.Time `json:"at"`
|
||||
Proactive bool `json:"proactive"` // true = 子主动写入;false = 系统自动写
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// ResidentOptions 是创建一个驻留子的参数(父的"创建"动作)。
|
||||
type ResidentOptions struct {
|
||||
// ID 是子 agent 的 id(同时是登记表的键、跨 agent 寻址的依据)。
|
||||
ID string
|
||||
// TaskPrompt 是在固定提示词之上注入的**任务提示词**。
|
||||
TaskPrompt string
|
||||
// InputChs 是**划入**给这个子的 inputch(单位 = inputch;可来自同一插件的多个)。
|
||||
InputChs []string
|
||||
// AllowedOutputs 是授权给它的输出通道集合(nil/空 = 完整授权)。
|
||||
AllowedOutputs []string
|
||||
// Capacity 是划入 inputch 的队列容量(0 = 内核默认)。
|
||||
Capacity int
|
||||
// TempPath 是它 temp 图记忆的存储路径(必填;与子同生共死)。
|
||||
TempPath string
|
||||
}
|
||||
|
||||
// ResidentInfo 是父对某个驻留子的可查询状态(登记表条目 + 状态面摘要)。
|
||||
type ResidentInfo struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
InputChs []string `json:"inputchs"`
|
||||
AllowedOutputs []string `json:"allowed_outputs"`
|
||||
Rounds int `json:"rounds"`
|
||||
ContextFull bool `json:"context_full"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TableSize int `json:"table_size"`
|
||||
Table []InputchRecord `json:"table,omitempty"`
|
||||
}
|
||||
|
||||
type residentChild struct {
|
||||
id string
|
||||
agent *Agent
|
||||
light *memory.LightMemory
|
||||
mainRO *memory.GraphDB
|
||||
tempPath string
|
||||
dir string
|
||||
inputChs []string
|
||||
allowed []string
|
||||
createdAt time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
state string // running | contextfull | stopped
|
||||
}
|
||||
|
||||
// mainGraph 返回父自己的完整图记忆库(驻留子的受限句柄由它派生)。
|
||||
func (a *Agent) mainGraph() *memory.GraphDB { return a.memory }
|
||||
|
||||
// SpawnResident 创建一个驻留子(父的"创建"动作)。
|
||||
func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
if strings.TrimSpace(opts.ID) == "" {
|
||||
return ResidentInfo{}, fmt.Errorf("驻留子必须有 id")
|
||||
}
|
||||
if strings.TrimSpace(opts.TempPath) == "" {
|
||||
return ResidentInfo{}, fmt.Errorf("驻留子必须给出 temp 图记忆路径")
|
||||
}
|
||||
main := a.mainGraph()
|
||||
if main == nil {
|
||||
return ResidentInfo{}, fmt.Errorf("父没有图记忆,无法为驻留子提供主库只读视图")
|
||||
}
|
||||
|
||||
a.residentMu.Lock()
|
||||
if a.residents == nil {
|
||||
a.residents = map[string]*residentChild{}
|
||||
}
|
||||
if _, dup := a.residents[opts.ID]; dup {
|
||||
a.residentMu.Unlock()
|
||||
return ResidentInfo{}, fmt.Errorf("驻留子 %s 已存在", opts.ID)
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
|
||||
// ① 轻量内核的记忆装配:主库**受限句柄**(结构上写不进)+ 自己的 temp 实例。
|
||||
// temp 目录由内核创建(调用方只给路径)——与子同生共死,销毁时整目录丢弃。
|
||||
if dir := filepath.Dir(opts.TempPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return ResidentInfo{}, fmt.Errorf("创建 temp 目录: %w", err)
|
||||
}
|
||||
}
|
||||
mainRO, err := memory.OpenGraphDBReadOnly(main.Path())
|
||||
if err != nil {
|
||||
return ResidentInfo{}, fmt.Errorf("打开主库受限句柄: %w", err)
|
||||
}
|
||||
light, err := memory.NewLightMemory(mainRO, opts.TempPath, true)
|
||||
if err != nil {
|
||||
_ = mainRO.Close()
|
||||
return ResidentInfo{}, err
|
||||
}
|
||||
|
||||
// ② 划入 inputch(登记表里记归属;一个插件的多个 inputch 可分别划给不同子)。
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
for _, ch := range opts.InputChs {
|
||||
if err := reg.Assign(ch, opts.ID, opts.Capacity); err != nil {
|
||||
_ = light.Close()
|
||||
_ = mainRO.Close()
|
||||
return ResidentInfo{}, fmt.Errorf("划入 inputch %s: %w", ch, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 子的 io:**独立**的 IOManager(自己的输入通道入口),但共享通道登记表。
|
||||
childIO := agentIO.NewIOManager()
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
childIO.SetChannelRegistry(reg)
|
||||
}
|
||||
|
||||
parentID := string(a.id)
|
||||
child := New(AgentConfig{
|
||||
ID: types.AgentID(opts.ID),
|
||||
Provider: a.provider,
|
||||
ProviderManager: a.providerManager,
|
||||
IO: childIO,
|
||||
StageHost: a.stageHost,
|
||||
LightMemory: light, // 轻量内核:只有图记忆共同面
|
||||
AllowedOutputs: opts.AllowedOutputs,
|
||||
KernelSource: parentID, // 子的 L4 只属于父
|
||||
ParentID: parentID,
|
||||
TaskPrompt: opts.TaskPrompt,
|
||||
})
|
||||
|
||||
rc := &residentChild{
|
||||
id: opts.ID, agent: child, light: light, mainRO: mainRO,
|
||||
tempPath: opts.TempPath, dir: filepath.Dir(opts.TempPath),
|
||||
inputChs: append([]string(nil), opts.InputChs...),
|
||||
allowed: append([]string(nil), opts.AllowedOutputs...),
|
||||
createdAt: time.Now(), state: "running",
|
||||
}
|
||||
|
||||
// ④ 子 → 父的主动消息(**L3 中断**,带子标识):投进父的 inputch。
|
||||
parentInCh := a.residentInboundChannel(opts.ID)
|
||||
child.notifyParent = func(text string) {
|
||||
a.io.InjectInterruptTextOpts(opts.ID, parentInCh, text,
|
||||
agentIO.InjectOptions{Priority: "L3"})
|
||||
}
|
||||
// ⑤ 子的 contextfull → 父侧的**内核级事件**(L4,带子标识)。
|
||||
child.onContextFull = func() { a.handleChildContextFull(rc) }
|
||||
|
||||
a.residentMu.Lock()
|
||||
a.residents[opts.ID] = rc
|
||||
a.residentMu.Unlock()
|
||||
|
||||
child.Start()
|
||||
|
||||
// ⑥ create 即开工:把任务提示词作为**第一条排队输入**投给子。
|
||||
//
|
||||
// 为什么必须在这里投:TaskPrompt 只进子的系统提示词("你是谁、要做什么"),
|
||||
// 而**不会**让子跑起来 —— 实测现象是子启动后 rounds=0、永远待机
|
||||
// (日志 `[agent] r1 started, waiting for IO interrupts` 之后无事发生)。
|
||||
// 走排队输入(非中断):创建是"安排工作",不是"打断它正在做的事"。
|
||||
if strings.TrimSpace(opts.TaskPrompt) != "" {
|
||||
child.io.InjectInputTo(a.residentParentSource(), parentInCh, "text",
|
||||
map[string]interface{}{"content": opts.TaskPrompt})
|
||||
}
|
||||
return rc.info(), nil
|
||||
}
|
||||
|
||||
// residentParentSource 是"父给子投递"的输入来源名(子的视角里能看出是谁发的)。
|
||||
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
|
||||
|
||||
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
|
||||
func (a *Agent) residentInboundChannel(childID string) string {
|
||||
ch := "child/" + childID
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
// 归属父自己:它是父的入站 inputch。
|
||||
_ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)})
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// DestroyResident 立刻销毁一个驻留子并从登记表移除(父的"销毁"动作;不收割)。
|
||||
//
|
||||
// 销毁是父**随时**可做的;父退出时由 StopResidents 对全部子执行。
|
||||
func (a *Agent) DestroyResident(id string) error {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
if ok {
|
||||
delete(a.residents, id)
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
a.teardownResident(rc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// teardownResident 停内核、放通道、丢 temp(销毁与回收共用)。
|
||||
func (a *Agent) teardownResident(rc *residentChild) {
|
||||
rc.mu.Lock()
|
||||
rc.state = "stopped"
|
||||
rc.mu.Unlock()
|
||||
|
||||
rc.agent.Stop() // 停子的调度器(取消其运行中的任务)
|
||||
if rc.light != nil {
|
||||
_ = rc.light.Close() // 关掉 temp 实例
|
||||
}
|
||||
if rc.mainRO != nil {
|
||||
_ = rc.mainRO.Close()
|
||||
}
|
||||
// temp 与子同生共死:连同目录一起丢弃(回收/销毁都不保留)。
|
||||
if rc.dir != "" && strings.Contains(rc.tempPath, rc.id) {
|
||||
_ = os.RemoveAll(rc.dir)
|
||||
}
|
||||
// 归还划入的 inputch(归属清空 ⇒ 回到"未分配",可再分配)。
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
for _, ch := range rc.inputChs {
|
||||
_ = reg.Assign(ch, "", 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopResidents 销毁全部驻留子(父退出时必须;不留孤儿)。
|
||||
func (a *Agent) StopResidents() int {
|
||||
a.residentMu.Lock()
|
||||
all := make([]*residentChild, 0, len(a.residents))
|
||||
for _, rc := range a.residents {
|
||||
all = append(all, rc)
|
||||
}
|
||||
a.residents = map[string]*residentChild{}
|
||||
a.residentMu.Unlock()
|
||||
for _, rc := range all {
|
||||
a.teardownResident(rc)
|
||||
}
|
||||
return len(all)
|
||||
}
|
||||
|
||||
// Residents 返回登记表的快照(按 id 排序,便于断言与展示稳定)。
|
||||
func (a *Agent) Residents() []ResidentInfo {
|
||||
a.residentMu.Lock()
|
||||
all := make([]*residentChild, 0, len(a.residents))
|
||||
for _, rc := range a.residents {
|
||||
all = append(all, rc)
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
out := make([]ResidentInfo, 0, len(all))
|
||||
for _, rc := range all {
|
||||
out = append(out, rc.info())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// ResidentTable 是父**查看**子的 inputch 处理表(pull,不打断子)。
|
||||
func (a *Agent) ResidentTable(id string) ([]InputchRecord, error) {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
return rc.agent.inputchTableSnapshot(), nil
|
||||
}
|
||||
|
||||
// SendToResident 是父的"发送消息":经输出通道寻址到该子的 inputch,
|
||||
// 对子而言是 **L4 中断**(取消当前状态 + 插入新消息)。
|
||||
func (a *Agent) SendToResident(id, text string) error {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
ch := "sub/" + id
|
||||
if len(rc.inputChs) > 0 {
|
||||
ch = rc.inputChs[0]
|
||||
}
|
||||
// 来源 = 父;子的 KernelSource 是父 ⇒ 在子的阶梯上这是合法的 L4。
|
||||
rc.agent.io.InjectInterruptTextOpts(string(a.id), ch, text,
|
||||
agentIO.InjectOptions{Priority: "L4"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompressResident 是父的"压缩"(**保留语义**):压上下文 + **清理处理表**,子继续存在。
|
||||
func (a *Agent) CompressResident(id string) (int, error) {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
dropped := rc.agent.context.TrimKeepRecent(residentKeepRecent)
|
||||
rc.agent.clearInputchTable() // 处理表记的是被压掉那段窗口的逐轮处理 ⇒ 必须清
|
||||
rc.agent.resetContextFull()
|
||||
rc.mu.Lock()
|
||||
rc.state = "running"
|
||||
rc.mu.Unlock()
|
||||
return dropped, nil
|
||||
}
|
||||
|
||||
// ReclaimResident 是父的"回收"(**取消语义**):父读子的 temp → 选记录 → 合入 main
|
||||
// → 丢弃 temp → **取消**该驻留子。
|
||||
//
|
||||
// keep 由父决定"哪些纳入记忆";为 nil 时表示全部合入。
|
||||
func (a *Agent) ReclaimResident(id string, keep func([]InputchRecord, []memory.Triple) []memory.Triple) (ResidentInfo, error) {
|
||||
a.residentMu.Lock()
|
||||
rc, ok := a.residents[id]
|
||||
a.residentMu.Unlock()
|
||||
if !ok {
|
||||
return ResidentInfo{}, fmt.Errorf("驻留子 %s 不存在", id)
|
||||
}
|
||||
info := rc.info()
|
||||
|
||||
var promoted int
|
||||
// 收割:读 temp 的全部活跃三元组(比通过图记录选择更直接)。
|
||||
if rc.light != nil && rc.light.Temp() != nil && a.mainGraph() != nil {
|
||||
exported, err := rc.light.Temp().ExportTriples(0)
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("读取子 temp 失败: %w", err)
|
||||
}
|
||||
selected := exported
|
||||
if keep != nil {
|
||||
selected = keep(info.Table, exported)
|
||||
}
|
||||
if len(selected) > 0 {
|
||||
if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+id, 0); err != nil {
|
||||
return info, fmt.Errorf("合入主记忆失败: %w", err)
|
||||
}
|
||||
promoted = len(selected)
|
||||
}
|
||||
}
|
||||
|
||||
// 收割完成 ⇒ 取消该驻留子(回收是取消语义,≠ 压缩)。
|
||||
if err := a.DestroyResident(id); err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.State = fmt.Sprintf("reclaimed(promoted=%d)", promoted)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// handleChildContextFull 把子的 contextfull 当**内核级事件**上报给父自己:
|
||||
// 父侧 L4 中断(带子标识)—— 只推信号,细节靠"查看"拉状态面。
|
||||
func (a *Agent) handleChildContextFull(rc *residentChild) {
|
||||
rc.mu.Lock()
|
||||
rc.state = "contextfull"
|
||||
rc.mu.Unlock()
|
||||
a.raiseKernelInterrupt("child/"+rc.id, "kernel",
|
||||
fmt.Sprintf("内核事件:驻留子 %s 上下文已满(contextfull)。请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", rc.id))
|
||||
}
|
||||
|
||||
// checkContextFull 判断本 agent 的**积累上下文是否已经装不下窗口**,是则触发一次 contextfull。
|
||||
//
|
||||
// ❗判据为什么不能写成"估算拼好的 f.Msgs":`buildMessages` 拿到的 `budget.ContextTokens`
|
||||
// 本身就是按 `targetUsage = 0.8 × 窗口` 算出来的,时间线**在拼进消息之前就被预算裁过**了。
|
||||
// 于是 `f.Msgs` 的规模结构上封顶在 ~80% 窗口 —— 对 90% 阈值而言是**永远不成立**的判据
|
||||
// (写测试时我用一个比系统提示词还小的窗口才勉强越过线,那等于什么都没测)。
|
||||
//
|
||||
// 正确的事是"**要被裁了**":拿**未裁剪**的积累上下文(`a.context` 的全部事件)估算,
|
||||
// 它超过窗口阈值就说明下一轮必须丢事件 ⇒ 这就是 contextfull。
|
||||
//
|
||||
// 只对**驻留子**生效(只有它们设了 onContextFull)。
|
||||
func (a *Agent) checkContextFull(f *TaskFrame) {
|
||||
if a.onContextFull == nil || a.ctxFullSignaled {
|
||||
return
|
||||
}
|
||||
max := 0
|
||||
if a.provider != nil {
|
||||
max = a.provider.MaxContextTokens()
|
||||
}
|
||||
if max <= 0 {
|
||||
max = defaultMaxContextTokens
|
||||
}
|
||||
if a.context == nil {
|
||||
return
|
||||
}
|
||||
// 未裁剪的积累上下文规模。
|
||||
acc := 0
|
||||
for _, e := range a.context.Recent(0) {
|
||||
acc += EstimateTokens(e.Input) + EstimateTokens(e.Response)
|
||||
}
|
||||
if float64(acc) < float64(max)*contextFullRatio {
|
||||
return
|
||||
}
|
||||
a.ctxFullSignaled = true
|
||||
a.onContextFull()
|
||||
}
|
||||
|
||||
func (a *Agent) resetContextFull() { a.ctxFullSignaled = false }
|
||||
|
||||
// ---- 子侧:inputch 处理表(子持有,父 pull) ----
|
||||
|
||||
// recordInputchNote 是子**主动写入**本轮 inputch 的处理信息(工具 inputch_note)。
|
||||
func (a *Agent) recordInputchNote(text string) {
|
||||
a.tableMu.Lock()
|
||||
defer a.tableMu.Unlock()
|
||||
a.inputchPending = &InputchRecord{
|
||||
InputCh: a.currentInputch, At: time.Now(), Proactive: true, Text: text,
|
||||
}
|
||||
}
|
||||
|
||||
// autoRecordInputch 是**系统自动写**兜底:本轮未主动写时,把该轮 inputch 的处理信息写入。
|
||||
// 保证每一轮必有记录,父不会看到空洞。
|
||||
func (a *Agent) autoRecordInputch(f *TaskFrame) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
a.tableMu.Lock()
|
||||
defer a.tableMu.Unlock()
|
||||
if a.inputchPending != nil {
|
||||
rec := *a.inputchPending
|
||||
a.inputchPending = nil
|
||||
rec.InputCh = firstNonEmpty(rec.InputCh, a.currentInputch)
|
||||
a.inputchTable = append(a.inputchTable, rec)
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("轮次完成:输入=%s", truncateStr(f.Input, 80))
|
||||
if f.Response != "" {
|
||||
text += ";产出=" + truncateStr(f.Response, 120)
|
||||
}
|
||||
a.inputchTable = append(a.inputchTable, InputchRecord{
|
||||
InputCh: a.currentInputch, At: time.Now(), Proactive: false, Text: text,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) inputchTableSnapshot() []InputchRecord {
|
||||
a.tableMu.Lock()
|
||||
defer a.tableMu.Unlock()
|
||||
return append([]InputchRecord(nil), a.inputchTable...)
|
||||
}
|
||||
|
||||
func (a *Agent) clearInputchTable() {
|
||||
a.tableMu.Lock()
|
||||
defer a.tableMu.Unlock()
|
||||
a.inputchTable = nil
|
||||
a.inputchPending = nil
|
||||
}
|
||||
|
||||
// ---- 子侧:主动向父发消息(L3) ----
|
||||
|
||||
func (a *Agent) notifyParentFrom(text string) string {
|
||||
if a.notifyParent == nil {
|
||||
return "本 agent 没有上级,无法发送消息"
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "消息内容不能为空"
|
||||
}
|
||||
a.notifyParent(text)
|
||||
return "已发送给主 agent"
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// residentKeepRecent 是压缩时保留的最近事件条数。
|
||||
const residentKeepRecent = 20
|
||||
|
||||
// contextFullRatio 是触发 contextfull 的占比:积累上下文超过窗口的这个比例就报。
|
||||
// 取 0.9 而不是 1.0:留一点余量,让父 agent 在"下一次必须丢事件"之前就能决策
|
||||
// (压缩 / 回收 / 销毁),而不是等已经丢了再报。
|
||||
const contextFullRatio = 0.9
|
||||
|
||||
// defaultMaxContextTokens 是 provider 未报告窗口时的兜底(与 ComputeTokenBudget 一致)。
|
||||
const defaultMaxContextTokens = 32768
|
||||
|
||||
func (rc *residentChild) info() ResidentInfo {
|
||||
rc.mu.Lock()
|
||||
state, full := rc.state, rc.state == "contextfull"
|
||||
rc.mu.Unlock()
|
||||
table := rc.agent.inputchTableSnapshot()
|
||||
info := ResidentInfo{
|
||||
ID: rc.id, State: state, InputChs: append([]string(nil), rc.inputChs...),
|
||||
AllowedOutputs: append([]string(nil), rc.allowed...),
|
||||
ContextFull: full, CreatedAt: rc.createdAt, TableSize: len(table),
|
||||
}
|
||||
if len(table) > 0 {
|
||||
info.Table = table
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// MarshalResidentInfo 便于工具输出(单工具多视图用 JSON 视图)。
|
||||
func MarshalResidentInfo(info ResidentInfo) string {
|
||||
b, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%+v", info)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
493
internal/agent/core/resident_test.go
Normal file
493
internal/agent/core/resident_test.go
Normal file
@ -0,0 +1,493 @@
|
||||
package core
|
||||
|
||||
// 驻留子:生命周期(N3)、跨 agent 投递(N4)、inputch 处理表(N5)、contextfull(N6)。
|
||||
//
|
||||
// 设计 docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// newRootWith 造一个带完整图记忆的父 agent,并指定它(以及它的驻留子)用的 provider。
|
||||
func newRootWith(t *testing.T, provider agentAPI.Provider) (*Agent, *memory.GraphDB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New(AgentConfig{
|
||||
ID: "parent",
|
||||
Provider: provider,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Memory: main,
|
||||
DataDir: dir,
|
||||
})
|
||||
a.Start() // 父也有自己的调度器:子的消息要真的进它的中断队列并被处理
|
||||
t.Cleanup(func() { a.Stop(); main.Close() })
|
||||
return a, main, dir
|
||||
}
|
||||
|
||||
// newRootForResidents 是默认构造(正常窗口)。
|
||||
func newRootForResidents(t *testing.T) (*Agent, *memory.GraphDB, string) {
|
||||
t.Helper()
|
||||
return newRootWith(t, &countingProvider{})
|
||||
}
|
||||
|
||||
func spawnTestResident(t *testing.T, parent *Agent, dir, id string, inputChs ...string) ResidentInfo {
|
||||
t.Helper()
|
||||
info, err := parent.SpawnResident(ResidentOptions{
|
||||
ID: id,
|
||||
TaskPrompt: "盯住这个通道,有情况就汇报",
|
||||
InputChs: inputChs,
|
||||
TempPath: filepath.Join(dir, "residents", id, "graph.db"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// waitFor 轮询直到条件成立(测试里不使用 sleep 猜时序)。
|
||||
func waitFor(t *testing.T, what string, fn func() bool) {
|
||||
t.Helper()
|
||||
waitForWithin(t, what, 5*time.Second, fn)
|
||||
}
|
||||
|
||||
func waitForWithin(t *testing.T, what string, within time.Duration, fn func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if fn() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("等待超时:%s", what)
|
||||
}
|
||||
|
||||
// ---- N3:生命周期 ----
|
||||
|
||||
func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
|
||||
// 父先注册两个 inputch(模拟插件注册),再把其中一个划给子。
|
||||
reg := parent.io.ChannelRegistry()
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "qq"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.Register(agentIO.InputChannel{Name: "sub/in", Plugin: "sub"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info := spawnTestResident(t, parent, dir, "child-1", "sub/in")
|
||||
if info.State != "running" {
|
||||
t.Fatalf("新建的驻留子状态=%q", info.State)
|
||||
}
|
||||
if list := parent.Residents(); len(list) != 1 || list[0].ID != "child-1" {
|
||||
t.Fatalf("登记表=%+v", list)
|
||||
}
|
||||
|
||||
// 划入生效:inputch 的归属变成子。
|
||||
ch, ok := reg.Lookup("sub/in")
|
||||
if !ok || ch.Owner != "child-1" {
|
||||
t.Fatalf("划入未生效:%+v", ch)
|
||||
}
|
||||
// 未划入的仍是未分配。
|
||||
if qq, _ := reg.Lookup("qq"); qq.Owner != "" {
|
||||
t.Fatalf("未划入的 inputch 不该有归属:%+v", qq)
|
||||
}
|
||||
// 父的入站 inputch(接收该子的消息)登记在父名下。
|
||||
if inbound, ok := reg.Lookup("child/child-1"); !ok || inbound.Owner != "parent" {
|
||||
t.Fatalf("父的入站 inputch 未登记:%+v ok=%v", inbound, ok)
|
||||
}
|
||||
|
||||
// 子的轻量内核:有共同面、没有整理面。
|
||||
c1 := parent.residents["child-1"]
|
||||
if c1.agent.memory != nil {
|
||||
t.Fatal("驻留子不该有记忆整理面")
|
||||
}
|
||||
if c1.agent.graphMem() == nil {
|
||||
t.Fatal("驻留子必须有图记忆共同面")
|
||||
}
|
||||
// 子的 L4 只属于父。
|
||||
if !c1.agent.isKernelLevelSource("parent") {
|
||||
t.Fatal("父必须是子的内核级来源(子的 L4 归父独占)")
|
||||
}
|
||||
if c1.agent.isKernelLevelSource("别人") {
|
||||
t.Fatal("非父来源不得成为子的内核级来源")
|
||||
}
|
||||
|
||||
// 销毁:出登记表、归还 inputch、temp 目录丢弃。
|
||||
if err := parent.DestroyResident("child-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parent.Residents()) != 0 {
|
||||
t.Fatal("销毁后登记表应为空")
|
||||
}
|
||||
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
|
||||
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
|
||||
}
|
||||
if err := parent.DestroyResident("child-1"); err == nil {
|
||||
t.Fatal("重复销毁应报错")
|
||||
}
|
||||
|
||||
// **父退出 ⇒ 全部子销毁、不留孤儿**。
|
||||
spawnTestResident(t, parent, dir, "c-a", "sub/in")
|
||||
spawnTestResident(t, parent, dir, "c-b")
|
||||
if n := parent.StopResidents(); n != 2 {
|
||||
t.Fatalf("StopResidents 销毁 %d 个,期望 2", n)
|
||||
}
|
||||
if len(parent.Residents()) != 0 {
|
||||
t.Fatal("父退出后登记表必须为空")
|
||||
}
|
||||
for _, id := range []string{"c-a", "c-b"} {
|
||||
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
|
||||
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// osStat 只是为了让"目录是否还存在"的断言可读(存在返回 nil 错误)。
|
||||
func osStat(path string) (interface{}, error) {
|
||||
_, err := os.Stat(path)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ---- N4:跨 agent 投递 ----
|
||||
|
||||
func TestResident_DeliveryBothDirections(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
|
||||
// 父 → 子:发送消息 ⇒ 子在 **L4** 上收到(父是子的内核级来源)。
|
||||
if err := parent.SendToResident("child-1", "先停一下,改做 X"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "子收到 L4 中断", func() bool {
|
||||
return child.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
|
||||
})
|
||||
|
||||
// 子 → 父:主动消息 ⇒ 父在 **L3** 上收到(不是 L4)。
|
||||
// 注意 L3 的枚举值是 LevelInteractive(LevelMessage 是 L2)。
|
||||
child.notifyParent("我这边发现了点东西")
|
||||
waitFor(t, "父收到子的 L3 消息", func() bool {
|
||||
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= 1
|
||||
})
|
||||
if got := parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical]; got != 0 {
|
||||
t.Fatalf("子的主动消息不得以 L4 出现在父的阶梯上(实际 %d 次)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N5:inputch 处理表 ----
|
||||
|
||||
func TestResident_InputchTableAutoAndProactive(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
|
||||
// 说明:create 会把任务提示词作为**第一条输入**投给子("create 即开工"),
|
||||
// 所以这里先等那一轮写完 —— 表里每多一轮就多一条,正是"每轮必有记录"。
|
||||
waitFor(t, "任务提示词那一轮写入", func() bool {
|
||||
table, err := parent.ResidentTable("child-1")
|
||||
return err == nil && len(table) >= 1
|
||||
})
|
||||
base, err := parent.ResidentTable("child-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := len(base)
|
||||
|
||||
// ① 子不主动写 ⇒ 系统自动写(每一轮必有记录)。
|
||||
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "干活"})
|
||||
waitFor(t, "自动写处理表", func() bool {
|
||||
table, err := parent.ResidentTable("child-1")
|
||||
return err == nil && len(table) == n+1 && !table[n].Proactive
|
||||
})
|
||||
table, err := parent.ResidentTable("child-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if table[n].InputCh != "sub/in" {
|
||||
t.Fatalf("处理表应记本轮 inputch,实际 %q", table[n].InputCh)
|
||||
}
|
||||
|
||||
// ② 子主动写 ⇒ 本轮不再自动写。
|
||||
child.recordInputchNote("本轮我自己记:已完成第一阶段")
|
||||
child.autoRecordInputch(&TaskFrame{Input: "第二轮"})
|
||||
table, err = parent.ResidentTable("child-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(table) != n+2 || !table[n+1].Proactive || !strings.Contains(table[n+1].Text, "第一阶段") {
|
||||
t.Fatalf("主动写优先的语义不成立:%+v", table)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N6:contextfull + 三种处置 ----
|
||||
|
||||
func TestResident_ContextFullAndDispositions(t *testing.T) {
|
||||
parent, main, dir := newRootForResidents(t)
|
||||
|
||||
// ① contextfull:把子的**积累上下文**(a.context,不是拼好的消息)灌到超过窗口 90%。
|
||||
// 注意不能靠"拼好的消息很大"来触发:拼装前时间线已被 token 预算裁到 ~80% 窗口。
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
rc := parent.residents["child-1"]
|
||||
child := rc.agent
|
||||
child.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(), Source: "sub/in",
|
||||
Input: strings.Repeat("上下文填充", 8000), // 40000 字 ≈ 80000 token ≫ 8192×0.9
|
||||
})
|
||||
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "继续"})
|
||||
|
||||
// 父在 **L4** 上收到 contextfull(带子标识)—— 只推信号。
|
||||
waitFor(t, "父收到 contextfull 的 L4 中断", func() bool {
|
||||
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
|
||||
})
|
||||
waitFor(t, "父的登记表显示子 contextfull", func() bool {
|
||||
for _, r := range parent.Residents() {
|
||||
if r.ID == "child-1" && r.ContextFull {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// ② 压缩(保留语义):上下文变短 + 处理表清空 + 子继续存在。
|
||||
child.recordInputchNote("压缩前的记录")
|
||||
if _, err := parent.CompressResident("child-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parent.Residents()) != 1 {
|
||||
t.Fatal("压缩后子必须继续存在(压缩是保留语义)")
|
||||
}
|
||||
if table, _ := parent.ResidentTable("child-1"); len(table) != 0 {
|
||||
t.Fatalf("压缩必须清理 inputch 处理表,实际 %d 条", len(table))
|
||||
}
|
||||
|
||||
// ③ 回收(取消语义):父选中的 temp 记录合入 main,然后取消该子。
|
||||
if _, _, err := child.graphMem().Commit([]memory.Triple{
|
||||
{Subject: "子的发现", Relation: "指向", Object: "结论"},
|
||||
}, "sess", 1); err != nil {
|
||||
t.Fatalf("子写自己的 temp 应成功: %v", err)
|
||||
}
|
||||
if _, err := parent.ReclaimResident("child-1", reclaimKeepAll); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parent.Residents()) != 0 {
|
||||
t.Fatal("回收是取消语义:子不该继续存在")
|
||||
}
|
||||
res, err := main.Recall([]string{"子的发现"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range res.Entities {
|
||||
if e.Name == "子的发现" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("回收应把选中的 temp 记录合入主记忆")
|
||||
}
|
||||
|
||||
// ④ 销毁:随时可做、立刻移除。
|
||||
spawnTestResident(t, parent, dir, "child-2")
|
||||
if err := parent.DestroyResident("child-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parent.Residents()) != 0 {
|
||||
t.Fatal("销毁后不该还在登记表里")
|
||||
}
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ---- N7:端到端 + 压力 ----
|
||||
|
||||
func TestResident_E2EAndStress(t *testing.T) {
|
||||
parent, _, dir := newRootForResidents(t)
|
||||
reg := parent.io.ChannelRegistry()
|
||||
|
||||
// 压力规模可用环境变量放大(默认 8 子 × 12 轮):
|
||||
// RESIDENT_STRESS_N / RESIDENT_STRESS_ROUNDS
|
||||
nResidents := envInt("RESIDENT_STRESS_N", 8)
|
||||
roundsEach := envInt("RESIDENT_STRESS_ROUNDS", 12)
|
||||
ids := make([]string, 0, nResidents)
|
||||
for i := 0; i < nResidents; i++ {
|
||||
id := "sub-" + string(rune('a'+i))
|
||||
ch := "sub/" + id + "/in"
|
||||
if err := reg.Register(agentIO.InputChannel{Name: ch, Plugin: "sub"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spawnTestResident(t, parent, dir, id, ch)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// 压力:每个子灌 roundsEach 轮输入;其中一半走父→子的 L4 消息,一半走普通输入。
|
||||
for _, id := range ids {
|
||||
for r := 0; r < roundsEach; r++ {
|
||||
// 内容必须唯一:内核会去重相同输入(去重路径不产生处理表记录)。
|
||||
msg := fmt.Sprintf("%s 第 %d 轮", id, r)
|
||||
if r%2 == 0 {
|
||||
if err := parent.SendToResident(id, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
parent.residents[id].agent.io.InjectInput("sub/"+id+"/in", "text",
|
||||
map[string]interface{}{"content": msg})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全部子都必须活着,且每一轮都留下处理表记录(自动或主动)。
|
||||
waitForWithin(t, "全部子完成各自轮次", 30*time.Second, func() bool {
|
||||
for _, id := range ids {
|
||||
table, err := parent.ResidentTable(id)
|
||||
if err != nil || len(table) < roundsEach/2 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// 双向通信在压力下也成立:让每个子都汇报一次(父侧 L3)。
|
||||
for _, id := range ids {
|
||||
parent.residents[id].agent.notifyParent("压力汇报 " + id)
|
||||
}
|
||||
waitForWithin(t, "父收到全部子的汇报", 10*time.Second, func() bool {
|
||||
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= uint64(nResidents)
|
||||
})
|
||||
|
||||
// 父退出 ⇒ 全部子销毁、登记表清空(不留孤儿)。
|
||||
if n := parent.StopResidents(); n != nResidents {
|
||||
t.Fatalf("父退出应销毁 %d 个子,实际 %d", nResidents, n)
|
||||
}
|
||||
if len(parent.Residents()) != 0 {
|
||||
t.Fatal("父退出后登记表必须为空")
|
||||
}
|
||||
t.Logf("压力通过:%d 个驻留子 × %d 轮(父→子 L4 与普通输入各半)+ 双向汇报",
|
||||
nResidents, roundsEach)
|
||||
}
|
||||
|
||||
// ---- 传统上下文:轻量内核不做动态上下文的裁剪 ----
|
||||
|
||||
// captureProvider 记录模型**实际收到**的消息。
|
||||
//
|
||||
// 为什么不直接看 TaskFrame:`prepareInputTask` 只做前半段(去重/通道/阶段/落上下文),
|
||||
// 消息是在 `runTaskSteps` 的 stepPrepare 里才拼出来的;而且断言"模型看到了什么"
|
||||
// 本来就比断言内核内部字段更接近事实。
|
||||
type captureProvider struct {
|
||||
countingProvider
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
messages []agentAPI.Message
|
||||
}
|
||||
|
||||
func (p *captureProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
if req != nil {
|
||||
p.messages = append([]agentAPI.Message(nil), req.Messages...)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return &agentAPI.CompletionResponse{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (p *captureProvider) chatText() (int, string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
var b strings.Builder
|
||||
for _, m := range p.messages {
|
||||
b.WriteString(m.Content)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return p.calls, b.String()
|
||||
}
|
||||
|
||||
// 子 agent 的上下文是**传统上下文**:累积的事件全部交给模型,
|
||||
// 内核**不得**按动态上下文的预算静默丢弃(那是父 agent 的能力)。
|
||||
// 装不下时由 contextfull 上报父决策,而不是自己丢。
|
||||
func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
|
||||
provider := &captureProvider{}
|
||||
parent, _, dir := newRootWith(t, provider)
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
|
||||
if !child.isLightKernel() {
|
||||
t.Fatal("驻留子必须被识别为轻量内核")
|
||||
}
|
||||
|
||||
// 先等"create 即开工"那一轮(任务提示词)跑完,否则下面抓到的是它的请求,
|
||||
// 而不是我们注入了大段上下文之后的那一轮。
|
||||
waitFor(t, "任务提示词那一轮结束", func() bool {
|
||||
table, err := parent.ResidentTable("child-1")
|
||||
return err == nil && len(table) >= 1
|
||||
})
|
||||
// 前提:动态上下文的份额 < 窗口(否则测不出区别)。
|
||||
b := ComputeTokenBudget(child.provider, child.systemPrompt)
|
||||
if b.MaxContext <= b.ContextTokens {
|
||||
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
|
||||
}
|
||||
// 填充量:**超过动态份额、但仍在窗口内**。
|
||||
// ⇒ 完整内核会因预算把最早那条裁掉;轻量内核不该裁(只受窗口硬上限约束)。
|
||||
filler := strings.Repeat("填", b.ContextTokens/2+200)
|
||||
if EstimateTokens(filler) <= b.ContextTokens {
|
||||
t.Fatalf("测试前提不成立:填充(%d token) 应超过动态份额(%d)", EstimateTokens(filler), b.ContextTokens)
|
||||
}
|
||||
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最早的事件标记EARLY"})
|
||||
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: filler})
|
||||
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最新的事件标记LATE"})
|
||||
|
||||
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "本轮输入"})
|
||||
|
||||
// 等**新的一轮**请求(首轮可能已经发过,必须严格等到注入之后那次)。
|
||||
waitFor(t, "子发出新一轮 LLM 请求", func() bool {
|
||||
n, _ := provider.chatText()
|
||||
return n >= 2
|
||||
})
|
||||
_, got := provider.chatText()
|
||||
if !strings.Contains(got, "最早的事件标记EARLY") {
|
||||
t.Fatalf("传统上下文:更早的事件不得被预算裁掉(属于父 agent 的动态上下文能力);实收消息长度=%d", len(got))
|
||||
}
|
||||
if !strings.Contains(got, "最新的事件标记LATE") {
|
||||
t.Fatal("最新事件必须在内")
|
||||
}
|
||||
}
|
||||
|
||||
// 对照:完整内核(根 agent)仍走动态上下文(按预算裁时间线)。
|
||||
func TestFullKernel_StillUsesDynamicContext(t *testing.T) {
|
||||
parent, _, _ := newRootForResidents(t)
|
||||
if parent.isLightKernel() {
|
||||
t.Fatal("根 agent 不是轻量内核")
|
||||
}
|
||||
b := ComputeTokenBudget(parent.provider, "sys")
|
||||
if got := parent.contextTokenBudget(b); got != b.ContextTokens {
|
||||
t.Fatalf("完整内核应使用动态上下文的 ContextTokens(%d),实际 %d", b.ContextTokens, got)
|
||||
}
|
||||
if b.MaxContext <= b.ContextTokens {
|
||||
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
|
||||
}
|
||||
}
|
||||
179
internal/agent/core/resident_tools.go
Normal file
179
internal/agent/core/resident_tools.go
Normal file
@ -0,0 +1,179 @@
|
||||
package core
|
||||
|
||||
// 驻留子的**工具面**(对照设计 §7 控制面与 §8 处理表)。
|
||||
//
|
||||
// 单工具多动作:父侧一个 `resident_agents`(list/create/send/inspect/compress/reclaim/destroy),
|
||||
// 子侧两个小工具:`notify_parent`(L3 主动汇报)与 `inputch_note`(主动写处理表)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
func strArg(tc agentAPI.ToolCall, key string) string {
|
||||
s, _ := tc.Arguments[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func splitArg(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// executeResidentAgents 是父的驻留子控制面(单工具多动作)。
|
||||
func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
|
||||
switch action := strArg(tc, "action"); action {
|
||||
case "", "list":
|
||||
list := a.Residents()
|
||||
if len(list) == 0 {
|
||||
return "当前没有驻留子 agent。"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "驻留子 agent(%d 个):", len(list))
|
||||
for _, r := range list {
|
||||
fmt.Fprintf(&b, "\n - %s [%s] inputch=%v 轮次=%d 处理表=%d",
|
||||
r.ID, r.State, r.InputChs, r.Rounds, r.TableSize)
|
||||
if r.ContextFull {
|
||||
b.WriteString(" ⚠️ contextfull")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
|
||||
case "create":
|
||||
id := strArg(tc, "id")
|
||||
tempPath := strArg(tc, "temp_path")
|
||||
if tempPath == "" {
|
||||
anchor := a.dataDir
|
||||
if anchor == "" {
|
||||
// 兜底:从**主图库路径**推导(<data>/memory/graph.db ⇒ <data>)。
|
||||
// 为什么不静默失败:这条路径只在"配置漏接线"时走到,
|
||||
// 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。
|
||||
if a.memory != nil && a.memory.Path() != "" {
|
||||
anchor = filepath.Dir(filepath.Dir(a.memory.Path()))
|
||||
log.Printf("[resident] data_dir 未接线,回退到主图库目录: %s", anchor)
|
||||
}
|
||||
}
|
||||
if anchor == "" {
|
||||
return "创建驻留子需要 data_dir 或显式 temp_path(内核未接线 DataDir)"
|
||||
}
|
||||
tempPath = filepath.Join(anchor, "residents", id, "graph.db")
|
||||
}
|
||||
info, err := a.SpawnResident(ResidentOptions{
|
||||
ID: id,
|
||||
TaskPrompt: strArg(tc, "task_prompt"),
|
||||
InputChs: splitArg(strArg(tc, "input_chs")),
|
||||
AllowedOutputs: splitArg(strArg(tc, "allowed_outputs")),
|
||||
Capacity: intArg(tc, "capacity"),
|
||||
TempPath: tempPath,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("创建驻留子失败: %v", err)
|
||||
}
|
||||
return "已创建驻留子: " + MarshalResidentInfo(info)
|
||||
|
||||
case "send":
|
||||
if err := a.SendToResident(strArg(tc, "id"), strArg(tc, "text")); err != nil {
|
||||
return fmt.Sprintf("发送失败: %v", err)
|
||||
}
|
||||
return "已发送(对子而言是 L4 中断)"
|
||||
|
||||
case "inspect":
|
||||
id := strArg(tc, "id")
|
||||
if id == "" {
|
||||
return "inspect 需要 id(或先用 action=list)"
|
||||
}
|
||||
table, err := a.ResidentTable(id)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("查看失败: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "驻留子 %s 的 inputch 处理表(%d 条):", id, len(table))
|
||||
for _, r := range table {
|
||||
kind := "系统写"
|
||||
if r.Proactive {
|
||||
kind = "主动写"
|
||||
}
|
||||
fmt.Fprintf(&b, "\n - [%s][%s] %s", r.InputCh, kind, r.Text)
|
||||
}
|
||||
if len(table) == 0 {
|
||||
b.WriteString("\n (尚无记录)")
|
||||
}
|
||||
return b.String()
|
||||
|
||||
case "compress":
|
||||
n, err := a.CompressResident(strArg(tc, "id"))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("压缩失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已压缩子 agent 上下文(丢弃 %d 条旧事件,并发清理其 inputch 处理表);子继续存在", n)
|
||||
|
||||
case "reclaim":
|
||||
info, err := a.ReclaimResident(strArg(tc, "id"), reclaimKeepAll)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("回收失败: %v", err)
|
||||
}
|
||||
return "已回收(temp 中选中的记录已合入主记忆,该驻留子已取消): " + MarshalResidentInfo(info)
|
||||
|
||||
case "destroy":
|
||||
if err := a.DestroyResident(strArg(tc, "id")); err != nil {
|
||||
return fmt.Sprintf("销毁失败: %v", err)
|
||||
}
|
||||
return "已销毁并移除该驻留子"
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("未知 action=%q;可用:list | create | send | inspect | compress | reclaim | destroy", action)
|
||||
}
|
||||
}
|
||||
|
||||
// reclaimKeepAll 是回收时的默认策略:把子 temp 的活跃记录全部纳入主记忆
|
||||
// ("哪些纳入"由父的模型决定——这里给的是"全要"这一档)。
|
||||
func reclaimKeepAll(_ []InputchRecord, triples []memory.Triple) []memory.Triple { return triples }
|
||||
|
||||
func intArg(tc agentAPI.ToolCall, key string) int {
|
||||
switch v := tc.Arguments[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// executeNotifyParent 是子的"主动向父发消息"(父侧阶梯 = **L3 中断**)。
|
||||
func (a *Agent) executeNotifyParent(tc agentAPI.ToolCall) string {
|
||||
return a.notifyParentFrom(strArg(tc, "text"))
|
||||
}
|
||||
|
||||
// executeInputchNote 是子"主动写入本轮 inputch 的处理信息"。
|
||||
// 主动写过 ⇒ 本轮系统不再自动写(见 autoRecordInputch)。
|
||||
func (a *Agent) executeInputchNote(tc agentAPI.ToolCall) string {
|
||||
text := strArg(tc, "text")
|
||||
if text == "" {
|
||||
return "text 不能为空"
|
||||
}
|
||||
a.recordInputchNote(text)
|
||||
return "已记录本轮 inputch 处理信息(本轮系统不会再自动写)"
|
||||
}
|
||||
|
||||
// childInboundChannelHint 是给子看的"父会怎么把消息投给你"的提示(不参与调度)。
|
||||
func childInboundChannelHint(a *Agent) string { return "sub/" + string(a.id) }
|
||||
|
||||
// residentTempDir 返回某个驻留子 temp 存储所在目录(销毁时连同目录丢弃)。
|
||||
func residentTempDir(tempPath string) string { return filepath.Dir(tempPath) }
|
||||
|
||||
var _ = agentIO.InputChannel{} // 保持 agentIO 依赖(工具面未来会用通道登记)
|
||||
937
internal/agent/core/scheduler.go
Normal file
937
internal/agent/core/scheduler.go
Normal file
@ -0,0 +1,937 @@
|
||||
package core
|
||||
|
||||
// 输入调度器:四级中断优先级 · 可抢占 · 现场保存/恢复。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md。
|
||||
//
|
||||
// # 模型(两类别 + 四级)
|
||||
//
|
||||
// 类别由**用哪个注入 API**决定,与通道名无关:
|
||||
// - TaskInterrupt:InjectInterrupt* 注入。带级别 L1..L4,可抢占,
|
||||
// 可被更高级中断打断(被打断的现场压入**中断栈**)。
|
||||
// - TaskQueued:InjectText*/InjectInputSync* 与内核自循环。**无级别**,
|
||||
// 用于“不需及时处理”的场景,可被**任何**中断打断。
|
||||
//
|
||||
// 级别只属于中断:
|
||||
// - L1..L3 由插件在 InjectOptions.Priority 里声明(见 clampPluginLevel);
|
||||
// - L4 给“立即打断”能力:内核自身(raiseKernelInterrupt:panic / selfip)
|
||||
// 与**内核级插件**(编译期内置插件,如 WebUI 终止按钮)可声明;
|
||||
// 外部插件经 proc 桥被夹到 L3,core 里也再判一次来源。
|
||||
//
|
||||
// # 选择顺序
|
||||
//
|
||||
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
|
||||
// 2. 中断队列 L4→L1(同级 FIFO)
|
||||
// 3. 中断栈顶(与 2 的队头比级别,取高者;栈顶无级别时中断必胜)
|
||||
// 4. 排队队列(FIFO)
|
||||
//
|
||||
// # 并发模型(不变量 I2)
|
||||
//
|
||||
// queue/running/栈/stats 只由 schedulerLoop 与调度 goroutine 写;
|
||||
// interruptLoop 只写中断登记与让位信号,**从不碰帧**。外部读取一律经
|
||||
// DumpScheduler() 加锁取快照。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// Level 是**中断**的优先级,由内核预定义四级。
|
||||
//
|
||||
// 语义:它衡量“这项工作有多不能等”,与具体通道名无关。
|
||||
// 插件在中断注入时通过 InjectOptions.Priority 声明 L1..L3;
|
||||
// **L4 由内核独占**(panic、内核事件 selfip),插件声明 L4 会被夹到 L3。
|
||||
//
|
||||
// 排队输入(InjectText* / InjectInputSync*)**没有级别**:它们本就是
|
||||
// “不需及时处理”的那一类,可被任何中断打断(见 TaskClass)。
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelBackground L1:完全可等。例:QQ/微信这类异步消息、批量通知。
|
||||
LevelBackground Level = 1
|
||||
// LevelMessage L2:一般提醒。例:插件希望尽快看到、但不紧急的提示。
|
||||
LevelMessage Level = 2
|
||||
// LevelInteractive L3:需及时处理。例:时钟/定时器到达、终端输出、交互输入。
|
||||
LevelInteractive Level = 3
|
||||
// LevelCritical L4:**内核独占**。panic 中断、内核事件中断(selfip)。
|
||||
// 插件不得声明此级。
|
||||
LevelCritical Level = 4
|
||||
)
|
||||
|
||||
// DefaultLevel 是未显式声明时的中断级别。
|
||||
//
|
||||
// 取最低级是刻意的:**显式才是特权**,新插件不会默认拿到抢占权。
|
||||
const DefaultLevel = LevelBackground
|
||||
|
||||
// clampPluginLevel 把**非内核级**来源声明的级别夹到 L1..L3。
|
||||
//
|
||||
// L4 是“立即打断”能力(panic / 内核事件 / 内核级插件的终止按钮),
|
||||
// 只给内核与编译期内置插件;外部插件声明 L4 会被夹到 L3。
|
||||
func clampPluginLevel(l Level) Level {
|
||||
if l < LevelBackground {
|
||||
return DefaultLevel
|
||||
}
|
||||
if l > LevelInteractive {
|
||||
return LevelInteractive
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelBackground:
|
||||
return "L1-background"
|
||||
case LevelMessage:
|
||||
return "L2-message"
|
||||
case LevelInteractive:
|
||||
return "L3-interactive"
|
||||
case LevelCritical:
|
||||
return "L4-critical"
|
||||
default:
|
||||
return "L?-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel 已删除。
|
||||
//
|
||||
// 为何不保留:优先级是**内核内部属性**,不是配置项。
|
||||
// 曾一度做成 `core.agent.priority.<channel>`(配置中心可见),
|
||||
// 那等于把内核的调度内部属性外化成运维配置,与设计意图相反。
|
||||
//
|
||||
// 现在级别的来源只有两个(见 Task/Level 注释):
|
||||
// - 插件在中断注入时声明(InjectOptions.Priority,L1..L3);
|
||||
// - 内核内部产生 L4(panic / selfip)。
|
||||
|
||||
// TaskClass 是任务的两大类别——**由“用哪个注入 API”决定,与通道名无关**。
|
||||
//
|
||||
// 这是模型的核心区分:
|
||||
// - InjectInterrupt* → TaskInterrupt:带级别,可抢占,可被更高级中断打断(→ 中断栈)
|
||||
// - InjectText* / InjectInputSync* / 内核自循环 → TaskQueued:无级别,
|
||||
// 可被**任何**中断打断(“用于不需要及时处理的场景”)
|
||||
type TaskClass int
|
||||
|
||||
const (
|
||||
TaskQueued TaskClass = iota
|
||||
TaskInterrupt
|
||||
)
|
||||
|
||||
func (c TaskClass) String() string {
|
||||
switch c {
|
||||
case TaskQueued:
|
||||
return "queued"
|
||||
case TaskInterrupt:
|
||||
return "interrupt"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// TaskKind 区分任务来源。
|
||||
type TaskKind int
|
||||
|
||||
const (
|
||||
// TaskKindInput 来自 io.InputChan(外部/插件注入的输入)。
|
||||
TaskKindInput TaskKind = iota
|
||||
// TaskKindSelf 来自 selfInputCh(内核自循环:记忆整理、子任务通知)。
|
||||
TaskKindSelf
|
||||
)
|
||||
|
||||
func (k TaskKind) String() string {
|
||||
switch k {
|
||||
case TaskKindInput:
|
||||
return "input"
|
||||
case TaskKindSelf:
|
||||
return "self"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Task 是调度器的最小单位。
|
||||
type Task struct {
|
||||
ID uint64
|
||||
Class TaskClass
|
||||
// Level 仅对 TaskInterrupt 有意义;TaskQueued 恒为 0(无级别)。
|
||||
Level Level
|
||||
Kind TaskKind
|
||||
EnqueuedAt time.Time
|
||||
|
||||
Event *agentIO.InputEvent // Kind == TaskKindInput
|
||||
Self selfInputMsg // Kind == TaskKindSelf
|
||||
|
||||
// PreemptCount 是本任务被抢占的次数,用于饥饿防护:
|
||||
// effectiveLevel = min(L4, Level + min(PreemptCount, 2))。
|
||||
PreemptCount int
|
||||
// LastPreemptAt 是上次被抢占的时刻,用于抢占冷却。
|
||||
LastPreemptAt time.Time
|
||||
}
|
||||
|
||||
// preemptPromotionCap 是抢占计数能带来的最大提升档数。
|
||||
const preemptPromotionCap = 2
|
||||
|
||||
// preemptCooldown 是“刚被抢占过”的冷却期:期内不再被抢占,
|
||||
// 避免高优先级流把同一任务反复打断到永不完结。
|
||||
const preemptCooldown = 2 * time.Second
|
||||
|
||||
// effectiveLevel 返回任务的**有效**级别。
|
||||
//
|
||||
// 排队输入恒为 0(无级别):任何中断(≥ L1)都大于它——这正好实现
|
||||
// “排队输入可被任何中断打断”。
|
||||
//
|
||||
// 中断则叠加饥饿防护:被抢占越多的中断越“值钱”,逐步追上抢占它的流;
|
||||
// 封顶 L4,因此它永远不会反过来抢占内核紧急中断。
|
||||
func effectiveLevel(t *Task) Level {
|
||||
if t.Class != TaskInterrupt {
|
||||
return 0
|
||||
}
|
||||
p := t.PreemptCount
|
||||
if p > preemptPromotionCap {
|
||||
p = preemptPromotionCap
|
||||
}
|
||||
l := t.Level + Level(p)
|
||||
if l > LevelCritical {
|
||||
l = LevelCritical
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// canPreempt 是唯一的抢占判据。
|
||||
//
|
||||
// 由于 effectiveLevel(排队)=0,这一个比较同时覆盖两条规则:
|
||||
// - running 是排队任务 → 任何中断(≥L1)都能抢占;
|
||||
// - running 是中断 Li → 只有 Lj > Li 的中断能抢占(严格大于)。
|
||||
func canPreempt(incoming, running *Task) bool {
|
||||
if incoming == nil || running == nil {
|
||||
return false
|
||||
}
|
||||
if incoming.Class != TaskInterrupt {
|
||||
return false // 排队输入从不抢占
|
||||
}
|
||||
return effectiveLevel(incoming) > effectiveLevel(running)
|
||||
}
|
||||
|
||||
// SchedulerStats 是调度器的累计计数(可观测性,设计文档 §11 O2)。
|
||||
//
|
||||
// InterruptsByLevel / PreemptsByLevel 按**中断级别**分桶(下标 1..4):
|
||||
// “各级中断各登记了多少、各真正抢断了多少次”。按级别验收(而不是只看总数)
|
||||
// 是这套调度器的核心判据——总数相同、级别分布不同,行为完全不同。
|
||||
type SchedulerStats struct {
|
||||
Enqueued uint64
|
||||
Executed uint64
|
||||
// Rejected 是因队列满(或深度超限)而未被接纳的次数。
|
||||
Rejected uint64
|
||||
// Suspended / Resumed 是挂起与恢复的次数。
|
||||
// 不变量:系统排空后 Suspended == Resumed(挂起必然被恢复),
|
||||
// 因此两者各自只在**一处**计数(suspend / resumeTask)。
|
||||
Suspended uint64
|
||||
Resumed uint64
|
||||
// InterruptsByLevel[1..4]:各级中断被**登记**的次数(含未抢占成功的)。
|
||||
InterruptsByLevel [5]uint64
|
||||
// PreemptsByLevel[1..4]:各级中断**判定为可抢占并进入 immediate**的次数。
|
||||
// 注意它与 Suspended 不等价:受害者可能在让位信号生效前就自行结束,
|
||||
// 此时抢占者仍然"下一个运行",但没有挂起发生。
|
||||
PreemptsByLevel [5]uint64
|
||||
}
|
||||
|
||||
// bumpInterruptLevel 按级别累加(级别必须落在 1..4,否则忽略——
|
||||
// 排队任务没有级别,不该出现在中断计数里)。
|
||||
func (st *SchedulerStats) bumpInterruptLevel(dst *[5]uint64, lv Level) {
|
||||
if lv >= LevelBackground && lv <= LevelCritical {
|
||||
dst[lv]++
|
||||
}
|
||||
}
|
||||
|
||||
// SchedulerSnapshot 是调度器的原子快照。
|
||||
type SchedulerSnapshot struct {
|
||||
Running *Task
|
||||
// Queue 是排队输入队列(无级别,FIFO)。
|
||||
Queue []*Task
|
||||
// InterruptQueues[level] 是四条中断队列(下标 1..4,同级 FIFO)。
|
||||
InterruptQueues [5][]*Task
|
||||
// Immediate 是刚抢占成功、将在下一个安全点立即运行的中断(最多一个)。
|
||||
Immediate *Task
|
||||
// PendingInterrupts = 四条中断队列 + Immediate(对外的待处理中断总数视图)。
|
||||
PendingInterrupts []*Task
|
||||
// SuspendStack:中断栈(含嵌套抢占的多个现场),**栈顶**优先恢复。
|
||||
SuspendStack []*suspendedTask
|
||||
Stats SchedulerStats
|
||||
// MaxInterruptFrames 是中断栈帧数的结构上界(= 中断级数,不是配置项)。
|
||||
MaxInterruptFrames int
|
||||
}
|
||||
|
||||
// schedulerStatus 把快照转成对外的状态 DTO(不暴露帧内容)。
|
||||
func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
|
||||
if a.sched == nil {
|
||||
return sdk.SchedulerStatus{}
|
||||
}
|
||||
snap := a.DumpScheduler()
|
||||
out := sdk.SchedulerStatus{
|
||||
ReadyQueueDepth: len(snap.Queue),
|
||||
PendingInterrupts: len(snap.PendingInterrupts),
|
||||
SuspendStack: len(snap.SuspendStack),
|
||||
MaxSuspendDepth: snap.MaxInterruptFrames,
|
||||
Enqueued: snap.Stats.Enqueued,
|
||||
Executed: snap.Stats.Executed,
|
||||
Rejected: snap.Stats.Rejected,
|
||||
Suspended: snap.Stats.Suspended,
|
||||
Resumed: snap.Stats.Resumed,
|
||||
Preempted: snap.Stats.Suspended,
|
||||
}
|
||||
if snap.Running != nil {
|
||||
out.Running = &sdk.SchedulerTask{
|
||||
ID: snap.Running.ID, Level: int(snap.Running.Level), Kind: snap.Running.Kind.String(),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type scheduler struct {
|
||||
mu sync.Mutex
|
||||
queue []*Task
|
||||
running *Task
|
||||
seq uint64
|
||||
stats SchedulerStats
|
||||
maxQueue int
|
||||
|
||||
// interruptQueues[level]:四条**中断队列**(level 1..4),同级 FIFO。
|
||||
// 未能立即抢占的中断(级别不足,或运行任务在临界区)按级别入队,
|
||||
// nextRef 从 L4 到 L1 依次扫描。
|
||||
interruptQueues [5][]*Task
|
||||
// immediate:刚抢占成功的中断。抢占必须**立即生效**,所以它不经队列,
|
||||
// 在下一个安全点直接运行。这也消除了“抢占者与被抢占者同级”的比较问题——
|
||||
// 抢占者根本不需要和栈顶比。
|
||||
immediate *Task
|
||||
// suspendStack:**中断栈**。被抢占后保存现场的任务压栈(LIFO),
|
||||
// 用于“中断被中断”的嵌套场景:只有**栈顶**参与恢复选择,栈内不做优先级重排。
|
||||
suspendStack []*suspendedTask
|
||||
// preemptArmed/preemptLevel:运行任务的“让位信号”。
|
||||
// interruptLoop 只写这两个字段与 pendingInterrupts;帧永远只由调度器读写。
|
||||
preemptArmed bool
|
||||
preemptLevel Level
|
||||
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
|
||||
// 由于 interceptLoop 要读它,必须是原子的:帧仍只由调度器读写。
|
||||
critical atomic.Bool
|
||||
// wake 用于把空闲的调度器叫醒:pendingInterrupts 不是 channel,
|
||||
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
|
||||
wake chan struct{}
|
||||
// maxInterruptFrames:中断栈帧数的**结构上界**,不是配置项。
|
||||
//
|
||||
// 链条 = 排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中),
|
||||
// 被挂起 4 帧;L4 之上没有更高级别,链到此为止。超限只可能是内核 bug,
|
||||
// 因此这里只做防御性计数,**不降级、不丢弃帧**。
|
||||
maxInterruptFrames int
|
||||
}
|
||||
|
||||
// suspendedTask 是一个被抢占任务的现场。
|
||||
type suspendedTask struct {
|
||||
Task *Task
|
||||
Frame *TaskFrame
|
||||
}
|
||||
|
||||
// nextSelection 标识 nextRef 从哪个集合取出任务。
|
||||
type nextSelection int
|
||||
|
||||
const (
|
||||
nextNone nextSelection = iota
|
||||
nextReady
|
||||
nextInterrupt
|
||||
nextImmediate
|
||||
nextSuspended
|
||||
)
|
||||
|
||||
func newScheduler(maxQueue int) *scheduler {
|
||||
if maxQueue <= 0 {
|
||||
maxQueue = 256
|
||||
}
|
||||
return &scheduler{
|
||||
maxQueue: maxQueue,
|
||||
maxInterruptFrames: int(LevelCritical), // 结构推论:= 中断级数
|
||||
wake: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// signalWake 非阻塞地唤醒调度器。
|
||||
func (s *scheduler) signalWake() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// setCritical 由调度器 goroutine 在任务进入/离开临界区时设置。
|
||||
func (s *scheduler) setCritical(v bool) { s.critical.Store(v) }
|
||||
|
||||
// inCritical 报告运行任务是否在不可抢占临界区。
|
||||
func (s *scheduler) inCritical() bool { return s.critical.Load() }
|
||||
|
||||
// hasRoom 报告排队队列是否还能接收任务。泵入侧据此节流:
|
||||
// 队列满则停止从 channel 取,让背压落回 channel 本身。
|
||||
func (s *scheduler) hasRoom() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.queue) < s.maxQueue
|
||||
}
|
||||
|
||||
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
|
||||
func (s *scheduler) allocateIDLocked(t *Task) {
|
||||
s.seq++
|
||||
t.ID = s.seq
|
||||
if t.EnqueuedAt.IsZero() {
|
||||
t.EnqueuedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue 把一个**排队输入**入队;队列满返回 false(调用方负责计数)。
|
||||
func (s *scheduler) enqueue(t *Task) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.queue) >= s.maxQueue {
|
||||
s.stats.Rejected++
|
||||
return false
|
||||
}
|
||||
s.allocateIDLocked(t)
|
||||
s.stats.Enqueued++
|
||||
s.queue = append(s.queue, t)
|
||||
return true
|
||||
}
|
||||
|
||||
// next 取出下一个要执行的任务;队列空返回 nil。
|
||||
//
|
||||
// 保留该签名供已有测试使用;调度器自用 nextRef(需要区分是否携带现场)。
|
||||
func (s *scheduler) next() *Task {
|
||||
t, _, _ := s.nextRef()
|
||||
return t
|
||||
}
|
||||
|
||||
// nextRef 选出下一个任务。优先顺序:
|
||||
//
|
||||
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
|
||||
// 2. 中断队列 L4→L1(同级 FIFO)
|
||||
// 3. 中断栈顶(与 2 比级别取高者;栈顶是排队任务时视为最低)
|
||||
// 4. 排队队列(FIFO)
|
||||
func (s *scheduler) nextRef() (*Task, *TaskFrame, nextSelection) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.immediate != nil {
|
||||
t := s.immediate
|
||||
s.immediate = nil
|
||||
s.running = t
|
||||
return t, nil, nextImmediate
|
||||
}
|
||||
|
||||
qTask, qLevel := s.highestInterruptLocked()
|
||||
|
||||
// 中断栈:只比**栈顶**(严格 LIFO)。栈内不做优先级重排——
|
||||
// 嵌套抢占天然使栈自底向上级别递增,且“后被打断的先恢复”才是栈语义。
|
||||
if n := len(s.suspendStack); n > 0 {
|
||||
top := s.suspendStack[n-1]
|
||||
// 栈顶 vs 最高级待处理中断:取高者(持平归栈顶,维持 LIFO 与公平)。
|
||||
if qTask == nil || effectiveLevel(top.Task) >= qLevel {
|
||||
// 这里只负责“选出”;Resumed 由 resumeTask 计一次(否则会双计,
|
||||
// 使“排空后 Suspended == Resumed”这条不变量失真)。
|
||||
s.suspendStack = s.suspendStack[:n-1]
|
||||
s.running = top.Task
|
||||
return top.Task, top.Frame, nextSuspended
|
||||
}
|
||||
}
|
||||
|
||||
if qTask != nil {
|
||||
s.popInterruptLocked(qLevel)
|
||||
s.running = qTask
|
||||
return qTask, nil, nextInterrupt
|
||||
}
|
||||
|
||||
if len(s.queue) > 0 {
|
||||
t := s.queue[0]
|
||||
s.queue = s.queue[1:]
|
||||
s.running = t
|
||||
return t, nil, nextReady
|
||||
}
|
||||
return nil, nil, nextNone
|
||||
}
|
||||
|
||||
// highestInterruptLocked 返回当前最高级非空中断队列的队头及其级别。
|
||||
func (s *scheduler) highestInterruptLocked() (*Task, Level) {
|
||||
for lv := LevelCritical; lv >= LevelBackground; lv-- {
|
||||
if q := s.interruptQueues[lv]; len(q) > 0 {
|
||||
return q[0], lv
|
||||
}
|
||||
}
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
// popInterruptLocked 弹出某级别中断队列的队头(调用方已确认非空)。
|
||||
func (s *scheduler) popInterruptLocked(lv Level) {
|
||||
s.interruptQueues[lv] = s.interruptQueues[lv][1:]
|
||||
}
|
||||
|
||||
// interruptCountLocked 统计所有待处理中断(含 immediate 槽)。
|
||||
func (s *scheduler) interruptCountLocked() int {
|
||||
n := 0
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
n += len(s.interruptQueues[lv])
|
||||
}
|
||||
if s.immediate != nil {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// setImmediateLocked 登记一个应“立即运行”的抢占者。
|
||||
//
|
||||
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
|
||||
func (s *scheduler) setImmediateLocked(t *Task) {
|
||||
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
|
||||
s.enqueueInterruptLocked(t)
|
||||
return
|
||||
}
|
||||
if s.immediate != nil {
|
||||
s.enqueueInterruptLocked(s.immediate)
|
||||
}
|
||||
s.allocateIDLocked(t)
|
||||
s.stats.Enqueued++
|
||||
s.immediate = t
|
||||
}
|
||||
|
||||
func removeTask(list []*Task, target *Task) []*Task {
|
||||
for i, t := range list {
|
||||
if t == target {
|
||||
return append(list[:i], list[i+1:]...)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// enqueueInterruptLocked 把一个未立即抢占的中断按其级别入队(调用方持锁)。
|
||||
//
|
||||
// 有界:满了丢**最老**的一条并计数(中断是提示性输入,宁可丢旧保新)。
|
||||
func (s *scheduler) enqueueInterruptLocked(t *Task) {
|
||||
s.allocateIDLocked(t)
|
||||
if s.interruptCountLocked() >= s.maxQueue {
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
if len(s.interruptQueues[lv]) > 0 {
|
||||
s.popInterruptLocked(lv)
|
||||
s.stats.Rejected++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
lv := t.Level
|
||||
if lv < LevelBackground || lv > LevelCritical {
|
||||
lv = DefaultLevel
|
||||
}
|
||||
s.interruptQueues[lv] = append(s.interruptQueues[lv], t)
|
||||
s.stats.Enqueued++
|
||||
}
|
||||
|
||||
// requestPreempt 登记一次中断请求(class=TaskInterrupt)。
|
||||
//
|
||||
// 返回 true 表示“应该尝试取消运行任务正在进行的可取消步骤(LLM 流式)”。
|
||||
//
|
||||
// 判据是 canPreempt(由优先级级别系统一承担),并受抢占冷却约束:
|
||||
// - running 是排队任务 → 任何中断都抢占;
|
||||
// - running 是中断 Li → 仅 Lj > Li 的中断抢占。
|
||||
//
|
||||
// 能抢占时把中断放进 immediate(立即生效);否则按其级别入队,等当前任务
|
||||
// 结束或下一个安全点再处理——无论哪种,中断都不会丢。
|
||||
//
|
||||
// 临界区(如记忆整理)内不 arm、不取消:中断只入队,等临界区结束后的安全点处理,
|
||||
// 这是设计 §4.3 的硬要求——那个位置的“不抢占”不能只是不让位,还必须不取消。
|
||||
// level 必须是**已解析好**的中断级别(含特权判定):
|
||||
// 生产路径只有 interruptLoop,它用 (*Agent).interruptLevel 得出 level;
|
||||
// 内核自身用 requestKernelPreempt(固定 L4)。本函数不再夹取,
|
||||
// 否则内核级插件的 L4 会被无辜削掉。
|
||||
func (s *scheduler) requestPreempt(evt *agentIO.InputEvent, level Level) bool {
|
||||
return s.registerInterrupt(newInterruptTask(evt, level))
|
||||
}
|
||||
|
||||
// requestKernelPreempt 是**内核**中断入口(panic / 内核事件 selfip)。
|
||||
//
|
||||
// 级别固定 L4,且**不夹取**——这是 L4 的唯一来源,插件永远够不到。
|
||||
func (s *scheduler) requestKernelPreempt(evt *agentIO.InputEvent) bool {
|
||||
return s.registerInterrupt(newKernelInterruptTask(evt))
|
||||
}
|
||||
|
||||
// registerInterrupt 是登记中断的公共实现(任务已带好 Class/Level)。
|
||||
func (s *scheduler) registerInterrupt(t *Task) bool {
|
||||
s.mu.Lock()
|
||||
running := s.running
|
||||
critical := s.critical.Load()
|
||||
s.stats.bumpInterruptLevel(&s.stats.InterruptsByLevel, t.Level)
|
||||
arm := false
|
||||
if !critical && canPreempt(t, running) {
|
||||
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
|
||||
arm = true
|
||||
s.preemptArmed = true
|
||||
s.preemptLevel = t.Level
|
||||
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
|
||||
s.setImmediateLocked(t)
|
||||
}
|
||||
}
|
||||
if !arm {
|
||||
s.enqueueInterruptLocked(t)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !arm {
|
||||
s.signalWake()
|
||||
}
|
||||
return arm
|
||||
}
|
||||
|
||||
// preemptGrantedFor 报告运行任务是否应在当前安全点让位。
|
||||
func (s *scheduler) preemptGrantedFor() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.preemptArmed || s.running == nil {
|
||||
return false
|
||||
}
|
||||
return s.preemptLevel > effectiveLevel(s.running)
|
||||
}
|
||||
|
||||
func (s *scheduler) clearPreempt() {
|
||||
s.mu.Lock()
|
||||
s.preemptArmed = false
|
||||
s.preemptLevel = 0
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// suspend 保存现场。
|
||||
//
|
||||
// 深度上界是**结构推论**(= 中断级数),不是配置项:安全点上的 canSuspend 已提前
|
||||
// 拦下超限情况,此处仅在竞态下兜底计数——绝不丢弃帧(帧丢了会丢副作用记录)。
|
||||
func (s *scheduler) suspend(t *Task, f *TaskFrame) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.suspendStack) >= s.maxInterruptFrames {
|
||||
s.stats.Rejected++
|
||||
}
|
||||
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: t, Frame: f})
|
||||
s.stats.Suspended++
|
||||
// 饥饿防护:抢占计数 +1(提升有效级)并记录冷却起点。
|
||||
t.PreemptCount++
|
||||
t.LastPreemptAt = time.Now()
|
||||
if s.running == t {
|
||||
s.running = nil
|
||||
}
|
||||
|
||||
// D1=B:中断任务在上一个任务之前的完整状态上开始运行,
|
||||
// 因此这里**不**把被打断任务的任何内容交给它。
|
||||
s.preemptArmed = false
|
||||
s.preemptLevel = 0
|
||||
}
|
||||
|
||||
// canSuspend 报告还有下潜余量(安全点用它决定是否真的让位)。
|
||||
func (s *scheduler) canSuspend() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.suspendStack) < s.maxInterruptFrames
|
||||
}
|
||||
|
||||
// done 标记任务执行结束。
|
||||
func (s *scheduler) done(t *Task) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.running == t {
|
||||
s.running = nil
|
||||
}
|
||||
// 任务正常结束:让位信号不再有意义(中断已在中断队列/immediate 里)。
|
||||
s.preemptArmed = false
|
||||
s.preemptLevel = 0
|
||||
s.stats.Executed++
|
||||
}
|
||||
|
||||
// currentLevel 返回当前正在执行任务的级别;无 running 时为默认级。
|
||||
//
|
||||
// 用于在 prepare 段把级别写进帧(抢占比较的基准)。
|
||||
// interruptLevel 返回一次**中断注入**的级别。
|
||||
//
|
||||
// 级别是“这项工作有多不能等”,由来源在 InjectOptions.Priority 里声明
|
||||
// (排队注入没有级别,它们的 TaskClass 是 TaskQueued)。
|
||||
//
|
||||
// privileged 表示来源是**内核级插件**(编译期内置插件,见 isKernelLevelSource):
|
||||
// - privileged=true → 可用到 L4(实现“立即打断”,如 WebUI 终止按钮)
|
||||
// - privileged=false → 夹到 L1..L3;空/非法一律降级为 DefaultLevel(L1)
|
||||
//
|
||||
// 另有完全绕过本函数的 L4 来源:内核自身的 raiseKernelInterrupt(panic / selfip)。
|
||||
func interruptLevel(evt *agentIO.InputEvent, privileged bool) Level {
|
||||
if evt == nil || evt.Payload == nil {
|
||||
return DefaultLevel
|
||||
}
|
||||
raw, _ := evt.Payload["priority"].(string)
|
||||
l, ok := parseInterruptLevel(raw)
|
||||
if !ok {
|
||||
return DefaultLevel
|
||||
}
|
||||
if privileged {
|
||||
return l
|
||||
}
|
||||
return clampPluginLevel(l)
|
||||
}
|
||||
|
||||
// isKernelLevelSource 报告某来源是否是**内核级插件**(编译期内置插件)。
|
||||
//
|
||||
// 只有它们能声明 L4(见 interruptLevel)。判据是插件注册表里的“内置工厂”,
|
||||
// 而不是插件自报的名字本身——外部插件经 proc 桥时已被夹到 L3,这里是第二道闸。
|
||||
//
|
||||
// source 的约定是 `插件名` 或 `插件名/实例`(如 webui/<deviceID>),故取第一段。
|
||||
func (a *Agent) isKernelLevelSource(source string) bool {
|
||||
if source == "" {
|
||||
return false
|
||||
}
|
||||
name := source
|
||||
if i := strings.IndexByte(name, '/'); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
// ① 编译期内置插件(根 agent 的 L4 来源之一)。
|
||||
if a.pluginReg != nil && a.pluginReg.IsBuiltinPlugin(name) {
|
||||
return true
|
||||
}
|
||||
// ② **本 agent 的上级**(驻留子的父)—— 设计 §6.1 的 L4 通则:
|
||||
// 子的阶梯上只有父能产生 L4,所以父的"发送消息"一定能打断子。
|
||||
return a.kernelSource != "" && name == a.kernelSource
|
||||
}
|
||||
|
||||
// parseInterruptLevel 解析插件声明的级别字符串("L1".."L3")。
|
||||
// 只认字面量:拼写错误必须降级成默认级而不是被静默当成别的级别。
|
||||
func parseInterruptLevel(s string) (Level, bool) {
|
||||
switch s {
|
||||
case "L1", "l1":
|
||||
return LevelBackground, true
|
||||
case "L2", "l2":
|
||||
return LevelMessage, true
|
||||
case "L3", "l3":
|
||||
return LevelInteractive, true
|
||||
case "L4", "l4":
|
||||
// 内核级:解析出来但会被 clamp 夹到 L3。
|
||||
return LevelCritical, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// newInputTask 把一个**排队输入**包装成任务(无级别)。
|
||||
func newInputTask(evt *agentIO.InputEvent) *Task {
|
||||
return &Task{Class: TaskQueued, Kind: TaskKindInput, Event: evt, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// newInterruptTask 把一个中断请求包装成任务(带级别)。
|
||||
func newInterruptTask(evt *agentIO.InputEvent, level Level) *Task {
|
||||
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// newSelfTask 包装内核自循环输入——它是**排队任务**:记忆整理/子任务通知
|
||||
// 不需要及时处理,可被任何中断打断。
|
||||
func newSelfTask(msg selfInputMsg) *Task {
|
||||
return &Task{Class: TaskQueued, Kind: TaskKindSelf, Self: msg, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// newKernelInterruptTask 构造一个**内核级中断**(L4)。
|
||||
//
|
||||
// 这是 L4 的唯一来源:panic 中断、内核事件中断(selfip)。
|
||||
// 插件永远拿不到这个入口——它不经 InjectOptions,也不经 proc 桥。
|
||||
func newKernelInterruptTask(evt *agentIO.InputEvent) *Task {
|
||||
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: LevelCritical, Event: evt, EnqueuedAt: time.Now()}
|
||||
}
|
||||
|
||||
// DumpScheduler 返回调度器的原子快照(供状态页/测试断言)。
|
||||
func (a *Agent) DumpScheduler() SchedulerSnapshot {
|
||||
if a.sched == nil {
|
||||
return SchedulerSnapshot{}
|
||||
}
|
||||
a.sched.mu.Lock()
|
||||
defer a.sched.mu.Unlock()
|
||||
snap := SchedulerSnapshot{Running: a.sched.running, Stats: a.sched.stats}
|
||||
snap.Queue = append(snap.Queue, a.sched.queue...)
|
||||
snap.Immediate = a.sched.immediate
|
||||
for lv := LevelBackground; lv <= LevelCritical; lv++ {
|
||||
snap.InterruptQueues[lv] = append(snap.InterruptQueues[lv], a.sched.interruptQueues[lv]...)
|
||||
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.interruptQueues[lv]...)
|
||||
}
|
||||
if a.sched.immediate != nil {
|
||||
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.immediate)
|
||||
}
|
||||
snap.SuspendStack = append(snap.SuspendStack, a.sched.suspendStack...)
|
||||
snap.MaxInterruptFrames = a.sched.maxInterruptFrames
|
||||
return snap
|
||||
}
|
||||
|
||||
// schedulerLoop 是唯一的任务执行者(取代原 eventLoop 的输入处理)。
|
||||
func (a *Agent) schedulerLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] schedulerLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.schedulerLoop()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
a.pumpInbox()
|
||||
|
||||
t, f, kind := a.sched.nextRef()
|
||||
if kind == nextNone {
|
||||
// 无待办:阻塞等新输入、新中断(wake)或退出。
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
case msg := <-a.selfInputCh:
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
case <-a.sched.wake:
|
||||
// 中断已入 pendingInterrupts,回到循环顶部重新挑选。
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if kind == nextSuspended {
|
||||
a.resumeTask(t, f)
|
||||
continue
|
||||
}
|
||||
a.executeNewTask(t)
|
||||
}
|
||||
}
|
||||
|
||||
// pumpInbox 把 channel 里**已就绪**的输入搬进就绪队列(非阻塞)。
|
||||
//
|
||||
// 为什么不直接边收边执行:先把已到达的输入收进队列,选择函数才有意义——
|
||||
// M3 起抢占必然要看"队列里还压着什么",而 channel 不是可枚举的结构。
|
||||
//
|
||||
// 队列满即停止泵入(背压落回 channel,语义与设计文档 §4.4 一致)。
|
||||
func (a *Agent) pumpInbox() {
|
||||
for a.sched.hasRoom() {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
a.sched.enqueue(newInputTask(evt))
|
||||
case msg := <-a.selfInputCh:
|
||||
a.sched.enqueue(newSelfTask(msg))
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。
|
||||
// raiseKernelInterrupt 是 **L4 的唯一入口**:panic 中断与内核事件中断(selfip)。
|
||||
//
|
||||
// 它不经 io.InputChan(那是外部/插件输入),而是直接向调度器登记一条内核中断:
|
||||
// 级别固定 L4、不夹取、不受插件声明影响。这正是“L4 只有内核持有”的落点。
|
||||
//
|
||||
// 能否抢占由调度器按统一判据决定;若会抢占,则顺手取消可取消的 LLM 流式步骤
|
||||
// (与 interceptLoop 对插件中断的处理完全一致)。
|
||||
func (a *Agent) raiseKernelInterrupt(source, channel, text string) {
|
||||
if a.sched == nil {
|
||||
return
|
||||
}
|
||||
evt := &agentIO.InputEvent{
|
||||
Source: source,
|
||||
Type: "interrupt",
|
||||
OutputChannel: channel,
|
||||
Payload: map[string]interface{}{
|
||||
"content": text,
|
||||
"interrupt": true,
|
||||
"interrupt_source": source,
|
||||
"interrupt_channel": channel,
|
||||
"kernel": true,
|
||||
},
|
||||
}
|
||||
if a.sched.requestKernelPreempt(evt) {
|
||||
a.cancelCurrentLLM()
|
||||
}
|
||||
}
|
||||
|
||||
// reportTaskPanic 把一个任务 panic 报告成内核 L4 中断。
|
||||
//
|
||||
// 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,则不再产生新的
|
||||
// L4——否则同一个 panic 会自我放大成中断风暴,与“内核事件”应有的语义相反。
|
||||
func (a *Agent) reportTaskPanic(t *Task, r interface{}) {
|
||||
if t.Class == TaskInterrupt && t.Level >= LevelCritical {
|
||||
return
|
||||
}
|
||||
a.raiseKernelInterrupt("kernel", "kernel",
|
||||
fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)", t.ID, r))
|
||||
}
|
||||
|
||||
func (a *Agent) executeTask(t *Task) {
|
||||
a.executeNewTask(t)
|
||||
}
|
||||
|
||||
// executeNewTask 执行一个**新建**任务,并做任务级 panic 隔离(不变量 I6)。
|
||||
//
|
||||
// 与改造前的差异(有意):原 eventLoop 在 panic 后重启整个循环,
|
||||
// 现在一个任务的 panic 只丢弃该任务,调度器与其它任务不受影响。
|
||||
func (a *Agent) executeNewTask(t *Task) {
|
||||
var f *TaskFrame
|
||||
var out stepOutcome = outcomeDone
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] task#%d (%s) panic recovered: %v\n%s",
|
||||
t.ID, t.Level, r, debug.Stack())
|
||||
a.reportTaskPanic(t, r)
|
||||
}
|
||||
}()
|
||||
switch t.Kind {
|
||||
case TaskKindInput:
|
||||
f, out = a.runInputTask(t.Event)
|
||||
case TaskKindSelf:
|
||||
f, out = a.runInputTask(selfEvent(t.Self))
|
||||
}
|
||||
}()
|
||||
|
||||
if out == outcomeSuspended && f != nil {
|
||||
a.sched.suspend(t, f)
|
||||
a.publishEvent(events.EventScheduler, map[string]interface{}{
|
||||
"action": "suspend", "task": t.ID, "level": int(t.Level),
|
||||
})
|
||||
return
|
||||
}
|
||||
a.sched.done(t)
|
||||
}
|
||||
|
||||
// resumeTask 从保存的现场继续一个被抢占的任务。
|
||||
//
|
||||
// 关键:不重建帧、不重跑 prepare 段——否则会重复提交上下文与事件。
|
||||
// resumeTask 从保存的现场继续一个被抢占的任务。
|
||||
//
|
||||
// 关键:不重跑 prepare 段(否则会重复提交上下文与事件),而是先把基础前缀
|
||||
// 重建到「中断任务之上」,再把本任务自己的现场接回去(见 rebaseFramePrefix)。
|
||||
func (a *Agent) resumeTask(t *Task, f *TaskFrame) {
|
||||
a.sched.mu.Lock()
|
||||
a.sched.stats.Resumed++
|
||||
a.sched.mu.Unlock()
|
||||
a.publishEvent(events.EventScheduler, map[string]interface{}{
|
||||
"action": "resume", "task": t.ID, "level": int(t.Level),
|
||||
})
|
||||
a.rebaseFramePrefix(f)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] resume task#%d panic recovered: %v\n%s",
|
||||
t.ID, r, debug.Stack())
|
||||
a.reportTaskPanic(t, r)
|
||||
a.sched.done(t)
|
||||
}
|
||||
}()
|
||||
|
||||
out := a.runTaskSteps(f)
|
||||
if out == outcomeSuspended {
|
||||
a.sched.suspend(t, f)
|
||||
return
|
||||
}
|
||||
a.finishInputTask(f, out)
|
||||
a.sched.done(t)
|
||||
}
|
||||
91
internal/agent/core/scheduler_channel_routing_test.go
Normal file
91
internal/agent/core/scheduler_channel_routing_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package core
|
||||
|
||||
// 抢占场景下的**输出通道路由**:被打断任务恢复后,回复必须回到它自己的输出通道。
|
||||
//
|
||||
// 回归判据(做驻留式子 agent 前必须成立):**内核不持有"当前通道"可变状态**。
|
||||
// 曾经有 agent 级字段 a.currentOutputChannel:只在 prepare 段写入,而被打断任务
|
||||
// 恢复时不重新 prepare(resumeTask 只 rebase 前缀),于是中断任务 prepare 时把它
|
||||
// 覆盖成自己的通道,被恢复的任务再把回复发到**中断任务的通道**上——两任务串台。
|
||||
// N0 已删除该字段:通道一律从输入事件/帧推导(outputChannelOf / f.OutputChannel)。
|
||||
//
|
||||
// 每任务回执(evt.ResponseCh,Target=evt.Source)不受影响,所以既有测试全绿;
|
||||
// 但按通道投递(OutputEvent.OutputChannel / events.EventAgentOutput 的 channel)
|
||||
// 是插件渲染给用户的路径,它会串。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPreempt_ResumeKeepsOwnOutputChannel(t *testing.T) {
|
||||
sp := newPreemptProvider("intr-done", "low-done")
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
// 排队任务,来源与输出通道都是 qq。
|
||||
lowEvt, lowCh := textEvent("qq", "低优先级任务")
|
||||
lowTask := newInputTask(lowEvt)
|
||||
if !a.sched.enqueue(lowTask) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
if lowEvt.OutputChannel != "qq" {
|
||||
t.Fatalf("前置条件不成立:OutputChannel=%q", lowEvt.OutputChannel)
|
||||
}
|
||||
|
||||
lt, _, kind := a.sched.nextRef()
|
||||
if kind != nextReady {
|
||||
t.Fatalf("应取到排队任务,kind=%v", kind)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { a.executeNewTask(lt); close(done) }()
|
||||
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("provider 未被调用")
|
||||
}
|
||||
|
||||
// cli 中断抢占(L3):任务被挂起。
|
||||
intrEvt, intrCh := textEvent("cli", "紧急打断")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
if !a.sched.requestPreempt(intrEvt, LevelInteractive) {
|
||||
t.Fatal("L3 中断应能抢占排队任务")
|
||||
}
|
||||
a.cancelCurrentLLM()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("被打断任务未挂起")
|
||||
}
|
||||
|
||||
// 中断任务先运行(它会 prepare,通道是 cli)。
|
||||
it, _, k := a.sched.nextRef()
|
||||
if k != nextImmediate {
|
||||
t.Fatalf("应取到立即运行的中断,kind=%v", k)
|
||||
}
|
||||
a.executeNewTask(it)
|
||||
if len(intrCh) != 1 {
|
||||
t.Fatalf("中断任务应回执一次,实际 %d", len(intrCh))
|
||||
}
|
||||
// 注意:这里**刻意**不再有任何"内核当前通道"可断言 —— 该字段已删除,
|
||||
// 通道只跟着输入事件与帧走。下面断言的就是这个性质本身。
|
||||
|
||||
// 恢复被抢占任务:它不重新 prepare,只能靠帧里记着自己的通道。
|
||||
rt, rf, k2 := a.sched.nextRef()
|
||||
if k2 != nextSuspended {
|
||||
t.Fatalf("应恢复被抢占任务,kind=%v", k2)
|
||||
}
|
||||
a.resumeTask(rt, rf)
|
||||
|
||||
select {
|
||||
case out := <-lowCh:
|
||||
if out.OutputChannel != "qq" {
|
||||
t.Fatalf("被打断任务恢复后的输出通道=%q,期望 qq —— 被中断任务的通道覆盖了 agent 级字段(两任务串台)",
|
||||
out.OutputChannel)
|
||||
}
|
||||
if out.Target != "qq" {
|
||||
t.Fatalf("回执 Target=%q,期望 qq", out.Target)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("被恢复任务未回执")
|
||||
}
|
||||
}
|
||||
167
internal/agent/core/scheduler_critical_test.go
Normal file
167
internal/agent/core/scheduler_critical_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package core
|
||||
|
||||
// M4 验收测试:临界区语义显式化 + 抢占延迟到安全点 + 批次不再被中断放弃。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §4.3(临界区)、§11.1(P5/P6)。
|
||||
//
|
||||
// 关键结构事实:让位检查**只在 step 之间**进行,因此任何正在执行的 step
|
||||
// (工具 RPC、ONNX、CAS 落盘)天然不可抢占——中断只能等它返回。
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// P5/P6:工具执行期间到达的高优先级中断不得立即抢占;必须等工具返回后的安全点。
|
||||
func TestPreempt_DeferredDuringToolExec(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var once sync.Once
|
||||
sh.RegisterTool("t_slow", sdk.ToolDef{Name: "t_slow", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
once.Do(func() { close(entered) })
|
||||
<-release
|
||||
return "slow-done", nil
|
||||
})
|
||||
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_slow")}}, // 低优先级任务调用慢工具
|
||||
{Content: "low-done"}, // 恢复后收尾
|
||||
{Content: "intr-done"}, // 中断任务
|
||||
}}
|
||||
a := New(AgentConfig{
|
||||
ID: "crit",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: sh,
|
||||
})
|
||||
|
||||
if _, _ = enqueueQueued(t, a, "qq", "低优先级任务"); true {
|
||||
}
|
||||
lt, _, _ := a.sched.nextRef()
|
||||
done := make(chan struct{})
|
||||
go func() { a.executeNewTask(lt); close(done) }()
|
||||
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("慢工具未被调用")
|
||||
}
|
||||
|
||||
// 工具执行中注入 L4 中断。
|
||||
intrEvt, _ := textEvent("cli", "紧急打断")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||
t.Fatal("L4 应 arm 让位信号")
|
||||
}
|
||||
// 关键断言:信号已 arm,但任务仍在工具里 —— 绝不能挂起。
|
||||
if !a.sched.preemptGrantedFor() {
|
||||
t.Fatal("让位信号应已 arm")
|
||||
}
|
||||
if a.DumpScheduler().Running == nil {
|
||||
t.Fatal("工具执行中不得挂起(StepToolExec 是临界区)")
|
||||
}
|
||||
if len(a.DumpScheduler().SuspendStack) != 0 {
|
||||
t.Fatal("工具执行中 suspendStack 应为空")
|
||||
}
|
||||
|
||||
// 放行工具 → 工具返回后的安全点才挂起。
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("工具返回后未挂起")
|
||||
}
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if len(snap.SuspendStack) != 1 {
|
||||
t.Fatalf("工具返回后 suspendStack=%d,期望 1", len(snap.SuspendStack))
|
||||
}
|
||||
if snap.SuspendStack[0].Frame.Step != StepToolAfter {
|
||||
t.Fatalf("应在工具执行后的安全点挂起(StepToolAfter),实际 %v", snap.SuspendStack[0].Frame.Step)
|
||||
}
|
||||
if len(snap.PendingInterrupts) != 1 {
|
||||
t.Fatalf("中断请求不得丢失,pendingInterrupts=%d", len(snap.PendingInterrupts))
|
||||
}
|
||||
}
|
||||
|
||||
// _consolidation_ 整任务视为不可抢占(它直接改图库)。
|
||||
func TestCriticalSection_ConsolidationMarked(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
// 判定:只有记忆整理通道是整任务临界区。
|
||||
if isCriticalChannel("cli") {
|
||||
t.Fatal("普通通道不应被判为临界区")
|
||||
}
|
||||
if !isCriticalChannel(channelConsolidation) {
|
||||
t.Fatal("记忆整理必须是不可抢占临界区")
|
||||
}
|
||||
|
||||
// 集成:标志的推导链「输入事件 → 通道 → isCriticalChannel → scheduler.critical」
|
||||
// 必须成立(N0 之后通道只从事件推导,不再有内核可变字段)。
|
||||
for _, c := range []struct {
|
||||
channel string
|
||||
want bool
|
||||
}{
|
||||
{"cli", false},
|
||||
{channelConsolidation, true},
|
||||
} {
|
||||
evt, _ := textEvent("tc", "x")
|
||||
evt.OutputChannel = c.channel
|
||||
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
|
||||
if got := a.sched.inCritical(); got != c.want {
|
||||
t.Fatalf("通道 %q → critical=%v,期望 %v", c.channel, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新语义:没有抢占时,同批的多个工具必须全部执行——不再有「中断放弃剩余批」。
|
||||
func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
var mu sync.Mutex
|
||||
var ran []string
|
||||
reg := func(name string) {
|
||||
sh.RegisterTool(name, sdk.ToolDef{Name: name, Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
mu.Lock()
|
||||
ran = append(ran, name)
|
||||
mu.Unlock()
|
||||
return name + "-out", nil
|
||||
})
|
||||
}
|
||||
reg("t_a")
|
||||
reg("t_b")
|
||||
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_a"), tc("c2", "t_b")}},
|
||||
{Content: "全部完成"},
|
||||
}}
|
||||
a := New(AgentConfig{
|
||||
ID: "batch",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: sh,
|
||||
})
|
||||
|
||||
if _, _ = enqueueQueued(t, a, "cli", "跑两个工具"); true {
|
||||
}
|
||||
tt, _, _ := a.sched.nextRef()
|
||||
a.executeNewTask(tt)
|
||||
|
||||
if len(ran) != 2 || ran[0] != "t_a" || ran[1] != "t_b" {
|
||||
t.Fatalf("同批工具应全部按序执行,实际 %v", ran)
|
||||
}
|
||||
snap := a.DumpScheduler()
|
||||
if len(snap.SuspendStack) != 0 || len(snap.PendingInterrupts) != 0 {
|
||||
t.Fatalf("无抢占时不应有挂起或待处理中断:%+v", snap)
|
||||
}
|
||||
if snap.Stats.Executed != 1 {
|
||||
t.Fatalf("Executed=%d,期望 1", snap.Stats.Executed)
|
||||
}
|
||||
}
|
||||
222
internal/agent/core/scheduler_e2e_test.go
Normal file
222
internal/agent/core/scheduler_e2e_test.go
Normal file
@ -0,0 +1,222 @@
|
||||
package core
|
||||
|
||||
// M7 验收测试:可观测性 + 压力 + 端到端。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §11.5(O1/O2)、§11.6(E1/E2)。
|
||||
//
|
||||
// 这一组与前几组的区别:前几组直接驱动调度器(确定性、可断言内部状态),
|
||||
// 这一组**完整启动** schedulerLoop + interceptLoop,经真实 channel 投递,
|
||||
// 验证组装后的行为与不变量。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
)
|
||||
|
||||
// countingProvider 只统计调用次数,永远成功。
|
||||
type countingProvider struct{ n atomic.Int64 }
|
||||
|
||||
func (p *countingProvider) Name() string { return "counting" }
|
||||
func (p *countingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.n.Add(1)
|
||||
return &agentAPI.CompletionResponse{Content: "ok"}, nil
|
||||
}
|
||||
func (p *countingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
return nil, errors.New("counting provider: no stream")
|
||||
}
|
||||
func (p *countingProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func waitQuiescent(t *testing.T, a *Agent, wantExecuted uint64, timeout time.Duration) SchedulerSnapshot {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Running == nil && len(snap.Queue) == 0 &&
|
||||
len(snap.PendingInterrupts) == 0 && len(snap.SuspendStack) == 0 &&
|
||||
snap.Stats.Executed >= wantExecuted {
|
||||
return snap
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("未在 %v 内排空:running=%v queue=%d pending=%d suspend=%d executed=%d",
|
||||
timeout, snap.Running != nil, len(snap.Queue), len(snap.PendingInterrupts),
|
||||
len(snap.SuspendStack), snap.Stats.Executed)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// 压力:N 个排队输入 + M 个中断,全部经真实 loop 执行,结束时三集合必须排空。
|
||||
func TestScheduler_StressMixedLoad(t *testing.T) {
|
||||
sp := &countingProvider{}
|
||||
a := New(AgentConfig{
|
||||
ID: "stress",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
const nInputs = 200
|
||||
const nInterrupts = 50
|
||||
|
||||
for i := 0; i < nInputs; i++ {
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": fmt.Sprintf("msg-%d", i)})
|
||||
}
|
||||
// 中断按 L1/L2/L3 轮转:把“四条中断队列按级别高→低扫描”真正压上,
|
||||
// 而不只是排空一条队列。
|
||||
levels := []string{"L1", "L2", "L3"}
|
||||
for i := 0; i < nInterrupts; i++ {
|
||||
a.io.InjectInterruptTextOpts("qq", "cli", fmt.Sprintf("intr-%d", i),
|
||||
agentIO.InjectOptions{Priority: levels[i%len(levels)]})
|
||||
}
|
||||
|
||||
snap := waitQuiescent(t, a, nInputs+nInterrupts, 30*time.Second)
|
||||
|
||||
if got := sp.n.Load(); got != int64(nInputs+nInterrupts) {
|
||||
t.Fatalf("LLM 调用=%d,期望 %d(每条输入/中断恰好一次)", got, nInputs+nInterrupts)
|
||||
}
|
||||
if snap.Stats.Rejected != 0 {
|
||||
t.Fatalf("容量充足却出现 Rejected=%d,说明背压/深度判定有误", snap.Stats.Rejected)
|
||||
}
|
||||
// 上次快照的计数在排空后应当稳定(不丢不重):等于入队后的执行数。
|
||||
if snap.Stats.Executed != uint64(nInputs+nInterrupts) {
|
||||
t.Fatalf("Executed=%d,期望 %d", snap.Stats.Executed, nInputs+nInterrupts)
|
||||
}
|
||||
}
|
||||
|
||||
// O2:每次挂起/恢复都产生一条 scheduler 事件。
|
||||
func TestObservability_SchedulerEventsAndStatus(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
var mu sync.Mutex
|
||||
var actions []string
|
||||
bus.Subscribe(events.EventScheduler, func(e *events.Event) {
|
||||
mu.Lock()
|
||||
actions = append(actions, fmt.Sprint(e.Payload["action"]))
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
sp := newPreemptProvider("intr-done", "low-done")
|
||||
a := New(AgentConfig{
|
||||
ID: "obs",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
EventBus: bus,
|
||||
})
|
||||
|
||||
// 直接驱动一次抢占-挂起-恢复(与 M3b 相同的手法)。
|
||||
lowEvt, _ := textEvent("qq", "低优先级")
|
||||
lowTask := &Task{Kind: TaskKindInput, Level: LevelBackground, Event: lowEvt, EnqueuedAt: time.Now()}
|
||||
a.sched.enqueue(lowTask)
|
||||
lt, _, _ := a.sched.nextRef()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { a.executeNewTask(lt); close(done) }()
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("provider 未进入")
|
||||
}
|
||||
|
||||
intrEvt, _ := textEvent("cli", "紧急")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
a.sched.requestKernelPreempt(intrEvt)
|
||||
a.cancelCurrentLLM()
|
||||
<-done
|
||||
|
||||
it, _, _ := a.sched.nextRef()
|
||||
a.executeNewTask(it)
|
||||
rt, rf, _ := a.sched.nextRef()
|
||||
a.resumeTask(rt, rf)
|
||||
|
||||
mu.Lock()
|
||||
got := strings.Join(actions, ",")
|
||||
mu.Unlock()
|
||||
if !strings.Contains(got, "suspend") || !strings.Contains(got, "resume") {
|
||||
t.Fatalf("调度事件缺失:%q", got)
|
||||
}
|
||||
|
||||
// 状态快照(供状态页/诊断):计数一致、三集合为空。
|
||||
st := a.GetKernelStatus().Scheduler
|
||||
if st.SuspendStack != 0 || st.PendingInterrupts != 0 || st.ReadyQueueDepth != 0 {
|
||||
t.Fatalf("排空后状态非空:%+v", st)
|
||||
}
|
||||
if st.Suspended == 0 || st.Resumed == 0 {
|
||||
t.Fatalf("挂起/恢复计数缺失:%+v", st)
|
||||
}
|
||||
if st.Executed < 2 {
|
||||
t.Fatalf("Executed=%d,期望 >=2", st.Executed)
|
||||
}
|
||||
if st.MaxSuspendDepth != 4 {
|
||||
t.Fatalf("MaxSuspendDepth=%d,期望 4", st.MaxSuspendDepth)
|
||||
}
|
||||
}
|
||||
|
||||
// E1/E2:完整启动 loop,经真实 channel 投递 L1 任务与 L4 中断,
|
||||
// 断言「LLM 流式中断 → 挂起 → 中断先完成 → 原任务恢复」的整条链路。
|
||||
func TestE2E_RealLoopPreemption(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
var mu sync.Mutex
|
||||
var actions []string
|
||||
bus.Subscribe(events.EventScheduler, func(e *events.Event) {
|
||||
mu.Lock()
|
||||
actions = append(actions, fmt.Sprint(e.Payload["action"]))
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
sp := newPreemptProvider("intr-done", "low-done")
|
||||
a := New(AgentConfig{
|
||||
ID: "e2e",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
EventBus: bus,
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// 排队输入:qq 入站消息 → 阻塞在第一次 LLM 调用(排队任务无级别)
|
||||
a.io.InjectInput("qq", "text", map[string]interface{}{"content": "长任务"})
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("低优先级任务未进入 LLM")
|
||||
}
|
||||
|
||||
// 插件声明的 L3 中断:interceptLoop 应取消 LLM 并登记抢占。
|
||||
// 注意它能打断**排队任务**不是因为级别高,而是因为排队任务无级别——
|
||||
// 任何中断都大于它。
|
||||
a.io.InjectInterruptTextOpts("cli", "cli", "紧急打断", agentIO.InjectOptions{Priority: "L3"})
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "后续常规输入"})
|
||||
|
||||
// 排空:中断任务 + 被恢复的原任务 + 后续常规输入
|
||||
snap := waitQuiescent(t, a, 3, 15*time.Second)
|
||||
|
||||
mu.Lock()
|
||||
got := strings.Join(actions, ",")
|
||||
mu.Unlock()
|
||||
if !strings.Contains(got, "suspend") || !strings.Contains(got, "resume") {
|
||||
t.Fatalf("E2E 未发生抢占-挂起-恢复:%q", got)
|
||||
}
|
||||
if snap.Stats.Executed < 3 {
|
||||
t.Fatalf("Executed=%d,期望 >=3", snap.Stats.Executed)
|
||||
}
|
||||
// 第一次 LLM 调用被丢弃 + 中断 1 + 恢复 1 + 常规输入 1 = 4
|
||||
if sp.callCount() != 4 {
|
||||
t.Fatalf("LLM 调用=%d,期望 4(丢弃 1 + 中断 1 + 恢复 1 + 常规 1)", sp.callCount())
|
||||
}
|
||||
}
|
||||
294
internal/agent/core/scheduler_kernel_test.go
Normal file
294
internal/agent/core/scheduler_kernel_test.go
Normal file
@ -0,0 +1,294 @@
|
||||
package core
|
||||
|
||||
// L4 的内核独占性 + 两类别抢占规则。
|
||||
//
|
||||
// 模型(用户明确):
|
||||
// - 类别由**用哪个注入 API** 决定,与通道名无关;
|
||||
// - L1..L3 由插件在 InjectOptions.Priority 声明;
|
||||
// - L4 只有内核持有(panic / 内核事件 selfip);
|
||||
// - 排队输入无级别,可被**任何**中断打断。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 非内核级来源声明 L4 必须被夹到 L3;内核级来源(内置插件)可用到 L4。
|
||||
func TestKernel_L4RequiresKernelLevelSource(t *testing.T) {
|
||||
if got := clampPluginLevel(LevelCritical); got != LevelInteractive {
|
||||
t.Fatalf("非特权声明 L4 应被夹到 L3,实际 %v", got)
|
||||
}
|
||||
cases := []struct {
|
||||
declared string
|
||||
want Level
|
||||
}{
|
||||
{"L1", LevelBackground},
|
||||
{"L2", LevelMessage},
|
||||
{"L3", LevelInteractive},
|
||||
{"l2", LevelMessage},
|
||||
{"L4", LevelInteractive}, // 非特权 → 夹到 L3
|
||||
{"L7", DefaultLevel}, // 未知 → 默认级
|
||||
{"", DefaultLevel}, // 未声明 → 默认级
|
||||
{"紧急", DefaultLevel}, // 拼写错误 → 默认级(不得被静默当成别的级别)
|
||||
}
|
||||
for _, c := range cases {
|
||||
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
|
||||
if c.declared != "" {
|
||||
evt.Payload["priority"] = c.declared
|
||||
}
|
||||
if got := interruptLevel(evt, false); got != c.want {
|
||||
t.Fatalf("非特权声明 %q → 级别 %v,期望 %v", c.declared, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := interruptLevel(nil, false); got != DefaultLevel {
|
||||
t.Fatalf("无事件应为默认级,实际 %v", got)
|
||||
}
|
||||
|
||||
// 特权(内核级插件):L4 被承认,其余待遇不变。
|
||||
for _, c := range []struct {
|
||||
declared string
|
||||
want Level
|
||||
}{
|
||||
{"L4", LevelCritical},
|
||||
{"L3", LevelInteractive},
|
||||
{"L1", LevelBackground},
|
||||
{"", DefaultLevel},
|
||||
{"L9", DefaultLevel},
|
||||
} {
|
||||
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
|
||||
if c.declared != "" {
|
||||
evt.Payload["priority"] = c.declared
|
||||
}
|
||||
if got := interruptLevel(evt, true); got != c.want {
|
||||
t.Fatalf("特权声明 %q → 级别 %v,期望 %v", c.declared, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内核级 = 插件注册表里的**内置工厂**(编译期自注册),与插件自报名无关;
|
||||
// source 约定 `插件名` 或 `插件名/实例`(如 webui/<deviceID>)。
|
||||
func TestKernel_KernelLevelSource(t *testing.T) {
|
||||
plugin.RegisterFactory("core_test_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||
return nil, nil
|
||||
})
|
||||
a := &Agent{pluginReg: plugin.NewRegistry()}
|
||||
|
||||
cases := []struct {
|
||||
source string
|
||||
want bool
|
||||
}{
|
||||
{"core_test_builtin", true},
|
||||
{"core_test_builtin/dev-1", true}, // 插件名/实例
|
||||
{"core_test_external", false},
|
||||
{"webui", false}, // 本测试注册表里没有 webui 工厂
|
||||
{"", false},
|
||||
{"core_test_builtinX", false}, // 不做前缀匹配
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := a.isKernelLevelSource(c.source); got != c.want {
|
||||
t.Fatalf("source=%q → %v,期望 %v", c.source, got, c.want)
|
||||
}
|
||||
}
|
||||
if (&Agent{}).isKernelLevelSource("core_test_builtin") {
|
||||
t.Fatal("没有插件注册表时不得授予内核级")
|
||||
}
|
||||
}
|
||||
|
||||
// 排队任务可被**任何**中断打断——包括最低的 L1。
|
||||
func TestKernel_QueuedTaskIsPreemptedByAnyInterrupt(t *testing.T) {
|
||||
s := newScheduler(8)
|
||||
q := newInputTask(&agentIO.InputEvent{Source: "plugin", OutputChannel: "plugin"})
|
||||
s.enqueue(q)
|
||||
if task, _, kind := s.nextRef(); task != q || kind != nextReady {
|
||||
t.Fatalf("应取到排队任务,kind=%v", kind)
|
||||
}
|
||||
|
||||
evt, _ := textEvent("qq", "最低级中断")
|
||||
if !s.registerInterrupt(newInterruptTask(evt, LevelBackground)) {
|
||||
t.Fatal("L1 中断也必须能打断排队任务(排队任务无级别)")
|
||||
}
|
||||
if s.immediate == nil || s.immediate.Level != LevelBackground {
|
||||
t.Fatalf("抢占者应进 immediate 槽,实际 %+v", s.immediate)
|
||||
}
|
||||
}
|
||||
|
||||
// 排队输入从不抢占——它没有级别,也就没有“比谁高”。
|
||||
func TestKernel_QueuedInputNeverPreempts(t *testing.T) {
|
||||
s := newScheduler(8)
|
||||
if !s.enqueue(newInputTask(&agentIO.InputEvent{Source: "a", OutputChannel: "a"})) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
s.nextRef() // running = 第一个排队任务
|
||||
if s.registerInterrupt(newInputTask(&agentIO.InputEvent{Source: "b", OutputChannel: "b"})) {
|
||||
t.Fatal("排队输入不得抢占任何任务")
|
||||
}
|
||||
}
|
||||
|
||||
// 内核 L4 入口不受夹取影响,且能抢占中断(L3)。
|
||||
func TestKernel_RequestKernelPreemptUsesL4(t *testing.T) {
|
||||
s := newScheduler(8)
|
||||
evt, _ := textEvent("cli", "L3 运行中")
|
||||
s.registerInterrupt(newInterruptTask(evt, LevelInteractive))
|
||||
s.nextRef() // running = L3 中断
|
||||
|
||||
kevt, _ := textEvent("kernel", "panic 中断")
|
||||
if !s.requestKernelPreempt(kevt) {
|
||||
t.Fatal("内核 L4 应能抢占 L3 中断")
|
||||
}
|
||||
if s.immediate == nil || s.immediate.Level != LevelCritical {
|
||||
t.Fatalf("内核中断必须是 L4,实际 %+v", s.immediate)
|
||||
}
|
||||
}
|
||||
|
||||
// 任务 panic → 内核 L4 中断(panic 是 L4 的来源之一)。
|
||||
func TestKernel_PanicRaisesL4Interrupt(t *testing.T) {
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已收到内核事件"}}}
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
// Event 为 nil:handleInput 解引用即 panic。
|
||||
bad := newInputTask(nil)
|
||||
if !a.sched.enqueue(bad) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
task, _, _ := a.sched.nextRef()
|
||||
a.executeTask(task) // panic 被隔离
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Immediate == nil {
|
||||
t.Fatal("panic 必须产生一条内核 L4 中断")
|
||||
}
|
||||
if snap.Immediate.Level != LevelCritical {
|
||||
t.Fatalf("panic 中断级别=%v,期望 L4", snap.Immediate.Level)
|
||||
}
|
||||
text, _ := snap.Immediate.Event.Payload["content"].(string)
|
||||
if !strings.Contains(text, "panic") {
|
||||
t.Fatalf("panic 中断应说明发生了什么,实际 %q", text)
|
||||
}
|
||||
if snap.Immediate.Event.Payload["kernel"] != true {
|
||||
t.Fatal("内核中断必须带 kernel 标记,便于与插件中断区分")
|
||||
}
|
||||
}
|
||||
|
||||
// 递归保护是结构性的:L4 内核中断自己 panic 时,不再产生新的 L4。
|
||||
func TestKernel_PanicInsideL4DoesNotRecurse(t *testing.T) {
|
||||
sp := &scriptProvider{}
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
evt, _ := textEvent("kernel", "内核事件")
|
||||
l4 := newKernelInterruptTask(evt)
|
||||
a.sched.immediate = l4
|
||||
task, _, _ := a.sched.nextRef()
|
||||
if task != l4 {
|
||||
t.Fatal("应取到 L4 内核中断")
|
||||
}
|
||||
a.executeTask(&Task{ID: task.ID, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput, Event: nil})
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Immediate != nil || len(snap.PendingInterrupts) != 0 {
|
||||
t.Fatalf("L4 自身 panic 不得再产生中断(否则自我放大),实际 immediate=%+v pending=%d",
|
||||
snap.Immediate, len(snap.PendingInterrupts))
|
||||
}
|
||||
}
|
||||
|
||||
// 中断栈的 4 帧上界是**结构推论**:排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)。
|
||||
func TestKernel_StackBoundIsFullChain(t *testing.T) {
|
||||
s := newScheduler(16)
|
||||
frame := func() *TaskFrame { return &TaskFrame{} }
|
||||
chain := []struct {
|
||||
class TaskClass
|
||||
lv Level
|
||||
}{
|
||||
{TaskQueued, 0},
|
||||
{TaskInterrupt, LevelBackground},
|
||||
{TaskInterrupt, LevelMessage},
|
||||
{TaskInterrupt, LevelInteractive},
|
||||
}
|
||||
for i := 0; i < len(chain)-1; i++ {
|
||||
s.suspend(&Task{ID: uint64(i + 1), Class: chain[i].class, Level: chain[i].lv}, frame())
|
||||
}
|
||||
if !s.canSuspend() {
|
||||
t.Fatal("3 帧挂起时仍应容得下 L3(第 4 级)继续下潜")
|
||||
}
|
||||
s.suspend(&Task{ID: 4, Class: TaskInterrupt, Level: LevelInteractive}, frame())
|
||||
if s.canSuspend() {
|
||||
t.Fatal("4 帧挂起 = 全链挂起(L4 运行中),不应再有下潜余量")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:插件声明 Priority → io.applyInjectOpts → payload → interruptLevel → 任务级别。
|
||||
// 这条链路断在任何一环,插件声明的级别都会静默失效(降级到 L1)。
|
||||
func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
|
||||
ioM := agentIO.NewIOManager()
|
||||
ioM.InjectInterruptTextOpts("qq", "cli", "通知", agentIO.InjectOptions{Priority: "L3"})
|
||||
|
||||
select {
|
||||
case evt := <-ioM.InputInterruptChan():
|
||||
if got := interruptLevel(evt, false); got != LevelInteractive {
|
||||
t.Fatalf("经 io 层后的级别=%v,期望 L3(payload=%v)", got, evt.Payload)
|
||||
}
|
||||
task := newInterruptTask(evt, interruptLevel(evt, false))
|
||||
if task.Class != TaskInterrupt || task.Level != LevelInteractive {
|
||||
t.Fatalf("中断任务类别/级别=%v/%v,期望 interrupt/L3", task.Class, task.Level)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("中断未到达 interruptCh")
|
||||
}
|
||||
|
||||
// 排队路径带 priority 也必须无效:排队输入没有级别。
|
||||
ioM.InjectTextOpts("qq", "cli", "普通输入", agentIO.InjectOptions{Priority: "L3"})
|
||||
select {
|
||||
case evt := <-ioM.InputChan():
|
||||
task := newInputTask(evt)
|
||||
if task.Class != TaskQueued || task.Level != 0 {
|
||||
t.Fatalf("排队任务类别/级别=%v/%v,期望 queued/无级别", task.Class, task.Level)
|
||||
}
|
||||
if effectiveLevel(task) != 0 {
|
||||
t.Fatalf("排队任务有效级=%v,期望 0", effectiveLevel(task))
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("排队输入未到达 inputCh")
|
||||
}
|
||||
}
|
||||
|
||||
// 内核级插件声明的 L4 必须一路到达调度器(“立即打断”能力,如 WebUI 终止按钮)。
|
||||
func TestKernel_KernelLevelPluginCanRaiseL4(t *testing.T) {
|
||||
plugin.RegisterFactory("core_test_l4", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||
return nil, nil
|
||||
})
|
||||
a := newPreemptAgent(t, &scriptProvider{})
|
||||
a.pluginReg = plugin.NewRegistry()
|
||||
|
||||
// 先让一个排队任务跑起来(无级别),才能看到“抢占”。
|
||||
evt, _ := textEvent("qq", "长任务")
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
a.sched.nextRef()
|
||||
|
||||
// 内核级插件(内置)声明 L4 的终止通知。
|
||||
kevt, _ := textEvent("core_test_l4", "用户按了终止按钮")
|
||||
kevt.Payload["priority"] = "L4"
|
||||
level := interruptLevel(kevt, a.isKernelLevelSource(kevt.Source))
|
||||
if level != LevelCritical {
|
||||
t.Fatalf("内核级插件声明 L4 应得 L4,实际 %v", level)
|
||||
}
|
||||
if !a.sched.requestPreempt(kevt, level) {
|
||||
t.Fatal("L4 应能打断排队任务")
|
||||
}
|
||||
if a.sched.immediate == nil || a.sched.immediate.Level != LevelCritical {
|
||||
t.Fatalf("应有一条 L4 中断在 immediate,实际 %+v", a.sched.immediate)
|
||||
}
|
||||
|
||||
// 反例:同样的声明来自外部插件 → 夹到 L3。
|
||||
eevt, _ := textEvent("core_test_external", "外部插件也想立即打断")
|
||||
eevt.Payload["priority"] = "L4"
|
||||
if got := interruptLevel(eevt, a.isKernelLevelSource(eevt.Source)); got != LevelInteractive {
|
||||
t.Fatalf("外部插件声明 L4 应被夹到 L3,实际 %v", got)
|
||||
}
|
||||
}
|
||||
356
internal/agent/core/scheduler_preempt_test.go
Normal file
356
internal/agent/core/scheduler_preempt_test.go
Normal file
@ -0,0 +1,356 @@
|
||||
package core
|
||||
|
||||
// M3b 验收测试:四级优先级 + 严格大于抢占 + 现场保存/恢复 + suspendStack。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §11(P1–P4、R1、R5、D1T、Q3)。
|
||||
//
|
||||
// 测试手法:用一个「第一次调用阻塞到 ctx 取消、之后按脚本返回」的 provider,
|
||||
// 让测试可以确定性地把运行任务停在 S_LLM 上,再注入中断观察让位与恢复。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// preemptProvider 第 1 次 Chat 阻塞直到 ctx 取消;第 2 次起返回脚本。
|
||||
type preemptProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
entered chan struct{}
|
||||
enteredOn sync.Once
|
||||
responses []string
|
||||
}
|
||||
|
||||
func newPreemptProvider(responses ...string) *preemptProvider {
|
||||
return &preemptProvider{entered: make(chan struct{}), responses: responses}
|
||||
}
|
||||
|
||||
func (p *preemptProvider) Name() string { return "preempt" }
|
||||
|
||||
func (p *preemptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
n := p.calls
|
||||
p.mu.Unlock()
|
||||
|
||||
if n == 1 {
|
||||
p.enteredOn.Do(func() { close(p.entered) })
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
i := n - 2
|
||||
if i < len(p.responses) {
|
||||
return &agentAPI.CompletionResponse{Content: p.responses[i]}, nil
|
||||
}
|
||||
return &agentAPI.CompletionResponse{Content: "done"}, nil
|
||||
}
|
||||
|
||||
func (p *preemptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
func (p *preemptProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func (p *preemptProvider) callCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.calls
|
||||
}
|
||||
|
||||
func newPreemptAgent(t *testing.T, sp agentAPI.Provider) *Agent {
|
||||
t.Helper()
|
||||
return New(AgentConfig{
|
||||
ID: "preempt",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
}
|
||||
|
||||
// enqueueQueued 入队一个**排队任务**(无级别)——对应 InjectText*/InjectInputSync*。
|
||||
func enqueueQueued(t *testing.T, a *Agent, source, content string) (*Task, *agentIO.InputEvent) {
|
||||
t.Helper()
|
||||
evt, _ := textEvent(source, content)
|
||||
task := newInputTask(evt)
|
||||
if !a.sched.enqueue(task) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
return task, evt
|
||||
}
|
||||
|
||||
// enqueueInterrupt 登记一次**中断**(走 requestPreempt:能抢占则进 immediate 槽,
|
||||
// 否则按级别进中断队列),返回被登记的任务。
|
||||
func enqueueInterrupt(t *testing.T, a *Agent, level Level, source, content string) (*Task, *agentIO.InputEvent) {
|
||||
t.Helper()
|
||||
evt, _ := textEvent(source, content)
|
||||
evt.Payload["interrupt"] = true
|
||||
a.sched.requestPreempt(evt, level)
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Immediate != nil && snap.Immediate.Event == evt {
|
||||
return snap.Immediate, evt
|
||||
}
|
||||
q := snap.InterruptQueues[clampPluginLevel(level)]
|
||||
for i := len(q) - 1; i >= 0; i-- {
|
||||
if q[i].Event == evt {
|
||||
return q[i], evt
|
||||
}
|
||||
}
|
||||
return nil, evt
|
||||
}
|
||||
|
||||
// P1 + R1 + R5 + D1=A:高优先级抢占 → 挂起在 S_LLM → 中断任务带只读前缀 →
|
||||
// 恢复后从 S_LLM 重发,且原任务的 msgs 未被改动。
|
||||
func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
|
||||
sp := newPreemptProvider("intr-done", "low-done")
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
lowTask, _ := enqueueQueued(t, a, "qq", "低优先级任务")
|
||||
lt, _, kind := a.sched.nextRef()
|
||||
if kind != nextReady || lt != lowTask {
|
||||
t.Fatalf("应取到低优先级任务,kind=%v", kind)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { a.executeNewTask(lt); close(done) }()
|
||||
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("provider 第 1 次调用未发生")
|
||||
}
|
||||
|
||||
// 注入 L4 中断(cli)
|
||||
intrEvt, _ := textEvent("cli", "紧急打断")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||
t.Fatal("L4 应请求抢占并返回 true(应取消 LLM)")
|
||||
}
|
||||
a.cancelCurrentLLM()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("低优先级任务未在取消后挂起")
|
||||
}
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Running != nil {
|
||||
t.Fatal("挂起后不应还有 running")
|
||||
}
|
||||
if len(snap.SuspendStack) != 1 {
|
||||
t.Fatalf("suspendStack=%d,期望 1", len(snap.SuspendStack))
|
||||
}
|
||||
if len(snap.PendingInterrupts) != 1 {
|
||||
t.Fatalf("pendingInterrupts=%d,期望 1", len(snap.PendingInterrupts))
|
||||
}
|
||||
sf := snap.SuspendStack[0].Frame
|
||||
if sf.Terminal != terminalSuspended {
|
||||
t.Fatalf("挂起任务终态=%v,期望 terminalSuspended", sf.Terminal)
|
||||
}
|
||||
if sf.Step != StepLLM {
|
||||
t.Fatalf("应在 StepLLM 安全点挂起,实际 step=%v", sf.Step)
|
||||
}
|
||||
msgsBefore := len(sf.Msgs)
|
||||
|
||||
// R5:第一次 LLM 调用被丢弃,未产生新消息。
|
||||
if sp.callCount() != 1 {
|
||||
t.Fatalf("挂起前 LLM 调用=%d,期望 1(不完整请求被丢弃)", sp.callCount())
|
||||
}
|
||||
|
||||
// Q3:三集合统一比较 → 下一轮取中断(L4 > L1)。
|
||||
it, _, k := a.sched.nextRef()
|
||||
if k != nextImmediate || it.Level != LevelCritical {
|
||||
t.Fatalf("应取到 pending 中断,kind=%v level=%v", k, it.Level)
|
||||
}
|
||||
// D1=B:中断任务在**上一个任务之前的完整状态**上开始运行,不继承本任务的现场。
|
||||
// 因此它能看到的唯一输入就是它自己携带的内容。
|
||||
if it.Event == nil {
|
||||
t.Fatal("中断任务必须携带自己的输入事件")
|
||||
}
|
||||
if got, _ := it.Event.Payload["content"].(string); got != "紧急打断" {
|
||||
t.Fatalf("中断任务输入=%q,期望 紧急打断", got)
|
||||
}
|
||||
a.executeNewTask(it)
|
||||
if len(a.DumpScheduler().PendingInterrupts) != 0 {
|
||||
t.Fatal("中断任务执行后 pendingInterrupts 应清空")
|
||||
}
|
||||
|
||||
// R1:恢复被抢占任务。基础前缀被重建到「中断任务之上」(含中断已提交的上下文),
|
||||
// 本任务自己的现场接回其后,然后从 S_LLM 重发。
|
||||
rt, rf, k2 := a.sched.nextRef()
|
||||
if k2 != nextSuspended || rt != lowTask {
|
||||
t.Fatalf("应恢复被抢占任务,kind=%v", k2)
|
||||
}
|
||||
if rf.Step != StepLLM {
|
||||
t.Fatalf("恢复游标=%v,期望 StepLLM", rf.Step)
|
||||
}
|
||||
a.resumeTask(rt, rf)
|
||||
|
||||
// 「加载回中断之上」的判据:恢复后的消息序列里必须出现中断任务的上下文。
|
||||
if !msgsContain(rf.Msgs, "紧急打断") {
|
||||
t.Fatal("恢复后的任务应看见中断任务的上下文(现场未加载回中断之上)")
|
||||
}
|
||||
// 本任务自己的现场(工具轮产物)仍然在。
|
||||
if len(rf.Msgs) < msgsBefore {
|
||||
t.Fatalf("恢复后消息数=%d,不应少于被抢占前的 %d", len(rf.Msgs), msgsBefore)
|
||||
}
|
||||
|
||||
if sp.callCount() != 3 {
|
||||
t.Fatalf("LLM 总调用=%d,期望 3(丢弃 1 + 中断 1 + 恢复 1)", sp.callCount())
|
||||
}
|
||||
snap = a.DumpScheduler()
|
||||
if len(snap.SuspendStack) != 0 || snap.Running != nil {
|
||||
t.Fatalf("全部结束后应无挂起与运行任务:%+v", snap)
|
||||
}
|
||||
if snap.Stats.Executed != 2 {
|
||||
t.Fatalf("Executed=%d,期望 2(中断任务 + 被抢占任务)", snap.Stats.Executed)
|
||||
}
|
||||
}
|
||||
|
||||
// P2/P3:同级与更低级都不得抢占,请求进中断队列。
|
||||
//
|
||||
// 注意:能比“同级/更低不得抢占”的只可能是**中断之间**——排队任务无级别,
|
||||
// 任何中断都能打断它(这是模型的规定,不是漏洞)。
|
||||
func TestPreempt_LowerOrEqualDoesNotPreempt(t *testing.T) {
|
||||
sp := newPreemptProvider("low-done", "intr-done")
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
lowTask, _ := enqueueInterrupt(t, a, LevelInteractive, "cli", "运行中的 L3")
|
||||
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
|
||||
t.Fatal("应取到运行中的 L3 中断")
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { a.executeNewTask(lowTask); close(done) }()
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("provider 未被调用")
|
||||
}
|
||||
|
||||
// 同级 L3
|
||||
e1, _ := textEvent("webui", "同级打断")
|
||||
if a.sched.requestPreempt(e1, LevelInteractive) {
|
||||
t.Fatal("同级不得抢占")
|
||||
}
|
||||
// 更低级 L1
|
||||
e2, _ := textEvent("system", "低优先级打断")
|
||||
if a.sched.requestPreempt(e2, LevelBackground) {
|
||||
t.Fatal("更低级不得抢占")
|
||||
}
|
||||
if a.sched.preemptGrantedFor() {
|
||||
t.Fatal("未 arm 让位信号,preemptGrantedFor 应为 false")
|
||||
}
|
||||
|
||||
// 运行任务没有被取消,仍在等它自己的 ctx;用取消让它收尾,便于清理。
|
||||
a.cancelCurrentLLM()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("运行任务未结束")
|
||||
}
|
||||
if len(a.DumpScheduler().PendingInterrupts) != 2 {
|
||||
t.Fatalf("两条未抢占中断都应保留在中断队列,实际 %d",
|
||||
len(a.DumpScheduler().PendingInterrupts))
|
||||
}
|
||||
}
|
||||
|
||||
// D1T:suspendStack 满时不再下潜(canSuspend=false),让位信号也不会 arm。
|
||||
func TestPreempt_DepthCapBlocksSuspension(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
if a.sched.maxInterruptFrames != 4 {
|
||||
t.Fatalf("默认栈深上界=%d,期望 4(= 中断级数,结构推论)", a.sched.maxInterruptFrames)
|
||||
}
|
||||
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
|
||||
for i := 0; i < a.sched.maxInterruptFrames; i++ {
|
||||
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: LevelBackground}, frame())
|
||||
}
|
||||
if a.sched.canSuspend() {
|
||||
t.Fatal("深度已达上限,canSuspend 应为 false")
|
||||
}
|
||||
// 超限兜底:仍保留帧(不丢副作用记录),但计数 Rejected。
|
||||
before := a.DumpScheduler().Stats.Rejected
|
||||
a.sched.suspend(&Task{ID: 99, Class: TaskInterrupt, Level: LevelBackground}, frame())
|
||||
if a.DumpScheduler().Stats.Rejected != before+1 {
|
||||
t.Fatal("超限挂起必须计数 Rejected")
|
||||
}
|
||||
if len(a.DumpScheduler().SuspendStack) != a.sched.maxInterruptFrames+1 {
|
||||
t.Fatal("兜底路径必须保留帧而不是丢弃")
|
||||
}
|
||||
}
|
||||
|
||||
// 中断不丢:空闲时请求抢占 → 不 arm 信号,但请求进 pendingInterrupts 并被选出。
|
||||
func TestPreempt_IdleInterruptIsQueuedNotLost(t *testing.T) {
|
||||
a := newPreemptAgent(t, &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已处理中断"}}})
|
||||
|
||||
evt, respCh := textEvent("qq", "空闲时的中断")
|
||||
if a.sched.requestPreempt(evt, LevelMessage) {
|
||||
t.Fatal("空闲时不应请求取消 LLM(没有运行任务)")
|
||||
}
|
||||
if len(a.DumpScheduler().PendingInterrupts) != 1 {
|
||||
t.Fatal("空闲时的中断必须进中断队列(不能丢)")
|
||||
}
|
||||
task, _, kind := a.sched.nextRef()
|
||||
if kind != nextInterrupt || task.Level != LevelMessage {
|
||||
t.Fatalf("应取到待处理中断,kind=%v", kind)
|
||||
}
|
||||
a.executeNewTask(task)
|
||||
if len(respCh) != 1 {
|
||||
t.Fatal("中断任务应完成并回执")
|
||||
}
|
||||
}
|
||||
|
||||
// msgsContain 报告消息序列里是否出现过某段文本(用于“现场是否合回”的断言)。
|
||||
func msgsContain(msgs []agentAPI.Message, sub string) bool {
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m.Content, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 恢复时的重建必须把 prepare 段对尾部消息的两处改写补回:
|
||||
// 中断标记(IsInterrupt)与多模态块(InputBlocks)。
|
||||
func TestPreempt_ResumeRebaseRestoresTailDecorations(t *testing.T) {
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "ok"}}}
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
block := agentAPI.ContentBlock{Type: "text", Text: "图"}
|
||||
f := a.newTaskFrame("打断文本", a.stageCtxFromInput("打断文本", "", ""))
|
||||
f.IsInterrupt = true
|
||||
f.InputBlocks = []agentAPI.ContentBlock{block}
|
||||
// 模拟 prepare 后的形状:基础前缀 + 一段“本任务自己的现场”
|
||||
f.Msgs = []agentAPI.Message{
|
||||
{Role: "system", Content: "S"},
|
||||
{Role: "user", Content: "[中断消息] 打断文本"},
|
||||
{Role: "assistant", Content: "进行中"},
|
||||
}
|
||||
f.PrefixLen = 2
|
||||
|
||||
a.rebaseFramePrefix(f)
|
||||
|
||||
if f.PrefixLen <= 0 || f.PrefixLen >= len(f.Msgs) {
|
||||
t.Fatalf("重建后 PrefixLen=%d,消息数=%d,前缀应短于总数", f.PrefixLen, len(f.Msgs))
|
||||
}
|
||||
// 尾部现场(assistant 进行中)必须还在最后。
|
||||
if last := f.Msgs[len(f.Msgs)-1]; last.Content != "进行中" {
|
||||
t.Fatalf("本任务现场应接回最后,实际 %+v", last)
|
||||
}
|
||||
prefixLast := f.Msgs[f.PrefixLen-1]
|
||||
if prefixLast.Role != "system" || !strings.HasPrefix(prefixLast.Content, "[中断消息]") {
|
||||
t.Fatalf("中断标记未补回:%+v", prefixLast)
|
||||
}
|
||||
if len(prefixLast.Blocks) != 1 || prefixLast.Blocks[0].Text != "图" {
|
||||
t.Fatalf("多模态块未补回:%+v", prefixLast.Blocks)
|
||||
}
|
||||
}
|
||||
101
internal/agent/core/scheduler_regression_test.go
Normal file
101
internal/agent/core/scheduler_regression_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
package core
|
||||
|
||||
// 回归判据:M3 实现与设计稿 §5.1/§4.3 的两处偏离。
|
||||
//
|
||||
// 这两条是**先写判据、确认失败、再修**的(修完保留为回归测试)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// cancelAwareProvider 第一次调用进入后阻塞,直到 ctx 取消;记录是否被取消。
|
||||
type cancelAwareProvider struct {
|
||||
once sync.Once
|
||||
entered chan struct{}
|
||||
canceled atomic.Bool
|
||||
}
|
||||
|
||||
func newCancelAwareProvider() *cancelAwareProvider {
|
||||
return &cancelAwareProvider{entered: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (p *cancelAwareProvider) Name() string { return "cancel-aware" }
|
||||
func (p *cancelAwareProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.once.Do(func() { close(p.entered) })
|
||||
<-ctx.Done()
|
||||
p.canceled.Store(true)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
func (p *cancelAwareProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
func (p *cancelAwareProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
// 设计 §5.1 ③:interruptLoop 定级/决策后必须**唤醒调度器**。
|
||||
// 现状:中断只被放进 pendingInterrupts,而调度器空闲时阻塞在 select(只看
|
||||
// InputChan/selfInputCh/ctx.Done)——没有任何东西会把它叫醒。
|
||||
func TestGap_IdleInterruptIsProcessed(t *testing.T) {
|
||||
sp := &countingProvider{}
|
||||
a := New(AgentConfig{
|
||||
ID: "idle-intr",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// 完全空闲时投递一条中断(模拟定时器通知/插件提醒)。
|
||||
a.io.InjectInterruptText("qq", "cli", "空闲时的通知")
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for sp.n.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if sp.n.Load() == 0 {
|
||||
snap := a.DumpScheduler()
|
||||
t.Fatalf("空闲时到达的中断未被处理:pendingInterrupts=%d(调度器未被唤醒)",
|
||||
len(snap.PendingInterrupts))
|
||||
}
|
||||
}
|
||||
|
||||
// 设计 §4.3/§5.2:`_consolidation_` 是整任务临界区——抢占请求必须排队等它结束,
|
||||
// 而不是取消它。现状:requestPreempt 不判临界区,interceptLoop 照常 cancelLLM,
|
||||
// 于是正在流式的记忆整理被中断 → stepLLM 直接以 error 结束(整理丢一半)。
|
||||
func TestGap_ConsolidationMustNotBeCancelled(t *testing.T) {
|
||||
sp := newCancelAwareProvider()
|
||||
a := New(AgentConfig{
|
||||
ID: "consol",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// 走自循环通道发起一次记忆整理。
|
||||
a.selfInputCh <- selfInputMsg{text: "合并实体", channel: channelConsolidation}
|
||||
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("记忆整理未进入 LLM 调用")
|
||||
}
|
||||
|
||||
// L4 中断到达。
|
||||
a.io.InjectInterruptText("cli", "cli", "L4 打断")
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if sp.canceled.Load() {
|
||||
t.Fatal("记忆整理是临界区,其 LLM 不该被取消(设计 §4.3/§5.2)")
|
||||
}
|
||||
}
|
||||
192
internal/agent/core/scheduler_stack_test.go
Normal file
192
internal/agent/core/scheduler_stack_test.go
Normal file
@ -0,0 +1,192 @@
|
||||
package core
|
||||
|
||||
// 中断栈(嵌套抢占)验收测试。
|
||||
//
|
||||
// 用户明确:存在**中断被中断**的场景,所以被打断的现场要压进**中断栈**。
|
||||
// 因此恢复纪律是**严格 LIFO(只比栈顶)**,而不是“全栈按优先级挑最优”。
|
||||
//
|
||||
// 为什么这个区别成立:抢占判据是 adopted.level > effectiveLevel(running),
|
||||
// 所以嵌套时栈自底向上的**基础级**天然递增;但饥饿防护的“有效级提升”会让
|
||||
// 栈内某个更老的任务有效级超过栈顶,此时“只比栈顶”才保证嵌套语义不被破坏。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// nestingProvider 第 1、2 次调用阻塞到 ctx 取消;第 3 次起按脚本返回。
|
||||
// 用序号精确对应 A(L1)→ B(L2)→ C(L3)→ 恢复 B → 恢复 A 的调用顺序。
|
||||
type nestingProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
entered chan int
|
||||
script []string
|
||||
}
|
||||
|
||||
func newNestingProvider(script ...string) *nestingProvider {
|
||||
return &nestingProvider{entered: make(chan int, 16), script: script}
|
||||
}
|
||||
|
||||
func (p *nestingProvider) Name() string { return "nesting" }
|
||||
func (p *nestingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
n := p.calls
|
||||
p.mu.Unlock()
|
||||
|
||||
p.entered <- n
|
||||
if n <= 2 {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
i := n - 3
|
||||
if i < len(p.script) {
|
||||
return &agentAPI.CompletionResponse{Content: p.script[i]}, nil
|
||||
}
|
||||
return &agentAPI.CompletionResponse{Content: "?"}, nil
|
||||
}
|
||||
func (p *nestingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
func (p *nestingProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func awaitEnter(t *testing.T, ch chan int, want int) {
|
||||
t.Helper()
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got != want {
|
||||
t.Fatalf("LLM 进入序号=%d,期望 %d", got, want)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("等第 %d 次 LLM 调用超时", want)
|
||||
}
|
||||
}
|
||||
|
||||
// 中断被中断:A(L1) → B(L2) → C(L3),恢复必须按 LIFO(B 先,A 后)。
|
||||
func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
|
||||
sp := newNestingProvider("c-done", "b-done", "a-done")
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
// A(L1)开始运行
|
||||
if _, _ = enqueueQueued(t, a, "qq", "任务A"); true {
|
||||
}
|
||||
at, _, _ := a.sched.nextRef()
|
||||
doneA := make(chan struct{})
|
||||
go func() { a.executeNewTask(at); close(doneA) }()
|
||||
awaitEnter(t, sp.entered, 1)
|
||||
|
||||
// B(L2)抢占 A
|
||||
bEvt, _ := textEvent("qq", "任务B")
|
||||
if !a.sched.requestPreempt(bEvt, LevelMessage) {
|
||||
t.Fatal("B(L2) 应抢占 A(L1)")
|
||||
}
|
||||
a.cancelCurrentLLM()
|
||||
<-doneA
|
||||
if n := len(a.DumpScheduler().SuspendStack); n != 1 {
|
||||
t.Fatalf("第一次抢占后栈深=%d,期望 1", n)
|
||||
}
|
||||
|
||||
// B 开始运行
|
||||
bt, _, k := a.sched.nextRef()
|
||||
if k != nextImmediate || bt.Level != LevelMessage {
|
||||
t.Fatalf("应取到 B(pending),kind=%v level=%v", k, bt.Level)
|
||||
}
|
||||
doneB := make(chan struct{})
|
||||
go func() { a.executeNewTask(bt); close(doneB) }()
|
||||
awaitEnter(t, sp.entered, 2)
|
||||
|
||||
// C(L3)抢占 B —— 这就是“中断被中断”
|
||||
cEvt, _ := textEvent("cli", "任务C")
|
||||
if !a.sched.requestPreempt(cEvt, LevelInteractive) {
|
||||
t.Fatal("C(L3) 应抢占 B(L2)")
|
||||
}
|
||||
a.cancelCurrentLLM()
|
||||
<-doneB
|
||||
|
||||
snap := a.DumpScheduler()
|
||||
if len(snap.SuspendStack) != 2 {
|
||||
t.Fatalf("嵌套后栈深=%d,期望 2", len(snap.SuspendStack))
|
||||
}
|
||||
if snap.SuspendStack[0].Task.Class != TaskQueued {
|
||||
t.Fatalf("栈底应为排队任务 A(无级别),实际 %v", snap.SuspendStack[0].Task.Class)
|
||||
}
|
||||
if snap.SuspendStack[1].Task.Level != LevelMessage {
|
||||
t.Fatalf("栈顶应为 B(L2),实际 %v", snap.SuspendStack[1].Task.Level)
|
||||
}
|
||||
|
||||
// C 运行完毕(第三次调用,不阻塞)
|
||||
ct, _, k := a.sched.nextRef()
|
||||
if k != nextImmediate || ct.Level != LevelInteractive {
|
||||
t.Fatalf("应取到 C,kind=%v level=%v", k, ct.Level)
|
||||
}
|
||||
a.executeNewTask(ct)
|
||||
|
||||
// LIFO:先恢复栈顶 B,再恢复 A
|
||||
rt, rf, k := a.sched.nextRef()
|
||||
if k != nextSuspended {
|
||||
t.Fatalf("应恢复栈顶,kind=%v", k)
|
||||
}
|
||||
if rt.Level != LevelMessage {
|
||||
t.Fatalf("应先恢复栈顶 B(L2),实际 %v", rt.Level)
|
||||
}
|
||||
a.resumeTask(rt, rf)
|
||||
|
||||
rt2, rf2, k2 := a.sched.nextRef()
|
||||
if k2 != nextSuspended {
|
||||
t.Fatalf("应继续恢复 A,kind=%v", k2)
|
||||
}
|
||||
if rt2.Class != TaskQueued {
|
||||
t.Fatalf("最后应恢复排队的 A(无级别),实际 %v", rt2.Class)
|
||||
}
|
||||
a.resumeTask(rt2, rf2)
|
||||
|
||||
if n := len(a.DumpScheduler().SuspendStack); n != 0 {
|
||||
t.Fatalf("全部恢复后栈应清空,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 只比栈顶:栈内更老的任务即使(因有效级提升)优先级更高,也不得越过栈顶。
|
||||
func TestStack_TopOnlyWinsOverHigherPrioritySuspended(t *testing.T) {
|
||||
a := newPreemptAgent(t, &scriptProvider{})
|
||||
|
||||
// 人为构造“A(L3) 在栈底、B(L2) 在栈顶”。真实抢占不会产生这种顺序
|
||||
// (栈自底向上基础级递增),这里专门用来区分两种实现:
|
||||
// · 只比栈顶 → 取 B
|
||||
// · 全栈扫最优 → 取 A(L3 > L2)
|
||||
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now()},
|
||||
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
|
||||
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelMessage, EnqueuedAt: time.Now()},
|
||||
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
|
||||
|
||||
rt, _, k := a.sched.nextRef()
|
||||
if k != nextSuspended {
|
||||
t.Fatalf("kind=%v,期望 nextSuspended", k)
|
||||
}
|
||||
if rt.ID != 2 {
|
||||
t.Fatalf("应取栈顶 B(ID=2),实际 ID=%d —— 说明在做全栈优先级扫描而非栈语义", rt.ID)
|
||||
}
|
||||
if n := len(a.DumpScheduler().SuspendStack); n != 1 {
|
||||
t.Fatalf("取出栈顶后栈深=%d,期望 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 深度上限对嵌套同样成立:到顶后新的抢占请求不再下潜。
|
||||
func TestStack_DepthCapDuringNesting(t *testing.T) {
|
||||
a := newPreemptAgent(t, &scriptProvider{})
|
||||
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
|
||||
for i := 0; i < a.sched.maxInterruptFrames; i++ {
|
||||
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: Level(i + 1)}, frame())
|
||||
}
|
||||
if a.sched.canSuspend() {
|
||||
t.Fatal("栈已满,canSuspend 应为 false")
|
||||
}
|
||||
if n := len(a.DumpScheduler().SuspendStack); n != a.sched.maxInterruptFrames {
|
||||
t.Fatalf("栈深=%d,期望上界 %d", n, a.sched.maxInterruptFrames)
|
||||
}
|
||||
}
|
||||
124
internal/agent/core/scheduler_starvation_test.go
Normal file
124
internal/agent/core/scheduler_starvation_test.go
Normal file
@ -0,0 +1,124 @@
|
||||
package core
|
||||
|
||||
// M5 验收测试:饥饿防护(抢占计数提升有效级 + 抢占冷却)。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §9、§11.5(G1/G2)。
|
||||
//
|
||||
// 为什么需要:固定四级 + 「严格大于才抢占」下,一条 L4 流可以反复打断同一个
|
||||
// L1 任务,使它永不完结。提升被抢占者的**有效**优先级,让它在竞争排队时
|
||||
// 逐步追上;封顶 L4,因此它永远抢不过真正的紧急输入(紧急输入本身不被抢占)。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStarvation_EffectiveLevelPromotion(t *testing.T) {
|
||||
base := &Task{Class: TaskInterrupt, Level: LevelBackground}
|
||||
if got := effectiveLevel(base); got != LevelBackground {
|
||||
t.Fatalf("未抢占时有效级=%v,期望 L1", got)
|
||||
}
|
||||
|
||||
base.PreemptCount = 1
|
||||
if got := effectiveLevel(base); got != LevelMessage {
|
||||
t.Fatalf("被抢占 1 次后有效级=%v,期望 L2", got)
|
||||
}
|
||||
base.PreemptCount = 2
|
||||
if got := effectiveLevel(base); got != LevelInteractive {
|
||||
t.Fatalf("被抢占 2 次后有效级=%v,期望 L3", got)
|
||||
}
|
||||
base.PreemptCount = 99
|
||||
if got := effectiveLevel(base); got != LevelInteractive {
|
||||
t.Fatalf("提升应封顶在 +2 档,实际 %v", got)
|
||||
}
|
||||
|
||||
// 封顶 L4:L3 任务被多次抢占也不会超过紧急级。
|
||||
high := &Task{Class: TaskInterrupt, Level: LevelInteractive, PreemptCount: 99}
|
||||
if got := effectiveLevel(high); got != LevelCritical {
|
||||
t.Fatalf("L3 提升后应封顶为 L4,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarvation_CooldownBlocksImmediateRepreempt(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = low
|
||||
a.sched.nextRef() // running = low
|
||||
|
||||
e1, _ := textEvent("qq", "第一次打断")
|
||||
if !a.sched.requestPreempt(e1, LevelMessage) {
|
||||
t.Fatal("L2 应能抢占 L1(首次)")
|
||||
}
|
||||
a.sched.suspend(low, a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")))
|
||||
if low.PreemptCount != 1 {
|
||||
t.Fatalf("PreemptCount=%d,期望 1", low.PreemptCount)
|
||||
}
|
||||
if low.LastPreemptAt.IsZero() {
|
||||
t.Fatal("挂起必须记录 LastPreemptAt(冷却起点)")
|
||||
}
|
||||
|
||||
// 冷却期内:即使 L4 也不得再抢占。
|
||||
a.sched.mu.Lock()
|
||||
a.sched.running = low
|
||||
a.sched.mu.Unlock()
|
||||
|
||||
e2, _ := textEvent("cli", "冷却期内的紧急打断")
|
||||
if a.sched.requestKernelPreempt(e2) {
|
||||
t.Fatal("抢占冷却期内不得再抢占")
|
||||
}
|
||||
if a.sched.preemptGrantedFor() {
|
||||
t.Fatal("冷却期内不得 arm 让位信号")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarvation_PromotionBlocksSameLevelPreempt(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
|
||||
a.sched.immediate = low
|
||||
a.sched.nextRef()
|
||||
// 模拟「已被抢占过一次」:有效级 = L2。
|
||||
low.PreemptCount = 1
|
||||
low.LastPreemptAt = time.Now().Add(-time.Hour) // 冷却已过
|
||||
|
||||
e1, _ := textEvent("qq", "同级打断")
|
||||
if a.sched.requestPreempt(e1, LevelMessage) {
|
||||
t.Fatal("有效级 L2 时,L2 中断不得抢占(严格大于才抢占)")
|
||||
}
|
||||
|
||||
e2, _ := textEvent("cli", "更高级打断")
|
||||
if !a.sched.requestPreempt(e2, LevelInteractive) {
|
||||
t.Fatal("L3 应能抢占有效级 L2")
|
||||
}
|
||||
if !a.sched.preemptGrantedFor() {
|
||||
t.Fatal("L3 > 有效 L2,应已 arm")
|
||||
}
|
||||
}
|
||||
|
||||
// 提升必须真的进入抢占判据,而不只是一个数学性质:
|
||||
// 被抢占过一次的 L1 中断(有效 L2)应当顶住同级 L2 流的再次抢占。
|
||||
func TestStarvation_PromotionIsVisibleInSelection(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
// 栈里放一个「被抢占过一次的 L1」:有效级 L2。
|
||||
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, PreemptCount: 1},
|
||||
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
|
||||
|
||||
// 队列里来一个 L2:有效级持平(2 vs 2)→ 不得越过栈顶。
|
||||
evt, _ := textEvent("qq", "L2 中断")
|
||||
a.sched.registerInterrupt(newInterruptTask(evt, LevelMessage))
|
||||
if _, _, kind := a.sched.nextRef(); kind != nextSuspended {
|
||||
t.Fatalf("有效级持平应恢复栈顶,kind=%v", kind)
|
||||
}
|
||||
|
||||
// 队列里来一个 L3:严格大于 → 队头优先。
|
||||
// PreemptCount 从 0 起:suspend 内部会 +1 → 有效级 L2(正好用来卡 L2 持平)。
|
||||
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelBackground},
|
||||
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
|
||||
evt2, _ := textEvent("cli", "L3 中断")
|
||||
a.sched.registerInterrupt(newInterruptTask(evt2, LevelInteractive))
|
||||
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
|
||||
t.Fatalf("L3 > 有效 L2 应取中断队列,kind=%v", kind)
|
||||
}
|
||||
}
|
||||
351
internal/agent/core/scheduler_stress_test.go
Normal file
351
internal/agent/core/scheduler_stress_test.go
Normal file
@ -0,0 +1,351 @@
|
||||
package core
|
||||
|
||||
// 优先级压力测试:**各级中断混合打入 + 排队输入**,全部经真实调度 loop 执行。
|
||||
//
|
||||
// 形状(用户指定):100 条中断(L1/L2/L3/L4 各 25,混合打入)+ 100 条排队输入。
|
||||
//
|
||||
// 为什么需要「等待合适的受害者再注入」:调度器是单线程的,同一时刻只有**一个**
|
||||
// 运行任务。如果闭着眼睛猛灌,绝大多数中断会落在「没有受害者」或「受害者级别
|
||||
// 不够」的时刻,于是全部退化成排队——压力测试就只压到了队列,没有压到抢占。
|
||||
// 因此每条中断都等到「运行中的任务按规则**应当**被它打断」时再注入:
|
||||
// - 受害者是排队任务(无级别)→ 任何中断都该抢占它;
|
||||
// - 受害者是中断 Li → 只有 Lj > Li 才该抢占它。
|
||||
//
|
||||
// 同时验证分级计数:各级登记了多少、各级真正抢断了多少次(只看总数会掩盖
|
||||
// 「总数一样但级别分布完全不同」这种情况)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// fixedDelayProvider 返回固定内容,并在被取消时立刻返回 ctx.Err()。
|
||||
//
|
||||
// 延迟是必需的:任务必须先「在跑」才谈得上被打断;取消感知也是必需的,
|
||||
// 否则抢占只能等它自然结束,测不到挂起/恢复。
|
||||
type fixedDelayProvider struct {
|
||||
n atomic.Int64
|
||||
delay time.Duration
|
||||
cancelled atomic.Int64 // 被 ctx 取消(即“流式段被抢占打断”)的次数
|
||||
}
|
||||
|
||||
func (p *fixedDelayProvider) Name() string { return "fixed-delay" }
|
||||
|
||||
func (p *fixedDelayProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
select {
|
||||
case <-time.After(p.delay):
|
||||
case <-ctx.Done():
|
||||
p.cancelled.Add(1)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
p.n.Add(1)
|
||||
return &agentAPI.CompletionResponse{Content: "fixed-reply"}, nil
|
||||
}
|
||||
|
||||
func (p *fixedDelayProvider) ChatStream(context.Context, *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
return nil, errors.New("fixed-delay provider: 非流式")
|
||||
}
|
||||
|
||||
func (p *fixedDelayProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
// waitForVictim 等到「适合被 lv 打断的受害者」正在运行,**且没有别的待处理中断**。
|
||||
//
|
||||
// 后半个条件很重要:若有待处理中断,本次注入只会进队列(中断队列先于排队任务
|
||||
// 被消费),压力就落在队列上而不是抢占/挂起路径上。
|
||||
// 返回 false 表示等到超时(调用方仍应注入,保持总量不变)。
|
||||
func waitForVictim(a *Agent, lv Level, wait time.Duration) bool {
|
||||
deadline := time.Now().Add(wait)
|
||||
for time.Now().Before(deadline) {
|
||||
snap := a.DumpScheduler()
|
||||
if r := snap.Running; r != nil && len(snap.PendingInterrupts) == 0 {
|
||||
if r.Class == TaskQueued {
|
||||
return true // 排队任务:任何中断都该抢占
|
||||
}
|
||||
if r.Class == TaskInterrupt && lv > r.Level {
|
||||
return true // 中断之间:严格更高级才该抢占
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestStress_MixedLevelInterruptsPlusQueuedInputs(t *testing.T) {
|
||||
const (
|
||||
nQueued = 100
|
||||
nInterrupts = 100
|
||||
// 窗口要够宽:受害者必须先"在跑",cancel 才来得及把它打断成挂起。
|
||||
// 太短(如 15ms)时任务常在让位信号生效前就自己跑完——抢占判为可行,
|
||||
// 但不会有挂起发生,压力就测不到现场保存/恢复。
|
||||
llmDelay = 40 * time.Millisecond
|
||||
)
|
||||
|
||||
// L4 只有内核级来源能声明,所以注册一个内置(编译期工厂)插件名。
|
||||
plugin.RegisterFactory("stress_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
sp := &fixedDelayProvider{delay: llmDelay}
|
||||
a := New(AgentConfig{
|
||||
ID: "stress-levels",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
PluginReg: plugin.NewRegistry(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// 第一波:100 条排队输入(无级别,FIFO)。
|
||||
for i := 0; i < nQueued; i++ {
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{
|
||||
"content": fmt.Sprintf("queued-%d", i),
|
||||
})
|
||||
}
|
||||
|
||||
// 给调度器一点时间真正开始跑排队任务,这样第一条中断就有受害者。
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// 第二波:100 条中断,级别 L1→L2→L3→L4 轮转(各 25 条)。
|
||||
// 每条都等到「该被它打断的受害者正在跑」时再注入。
|
||||
levels := []Level{LevelBackground, LevelMessage, LevelInteractive, LevelCritical}
|
||||
names := map[Level]string{
|
||||
LevelBackground: "L1", LevelMessage: "L2", LevelInteractive: "L3", LevelCritical: "L4",
|
||||
}
|
||||
// L4 必须来自内核级插件来源;其余用普通来源(cli 是内置名,但级别只有 L1..L3 时无所谓)。
|
||||
src := func(lv Level) string {
|
||||
if lv == LevelCritical {
|
||||
return "stress_builtin"
|
||||
}
|
||||
return "cli"
|
||||
}
|
||||
for i := 0; i < nInterrupts; i++ {
|
||||
lv := levels[i%len(levels)]
|
||||
waitForVictim(a, lv, 3*time.Second)
|
||||
a.io.InjectInterruptTextOpts(src(lv), "cli", fmt.Sprintf("irq-%s-%d", names[lv], i),
|
||||
agentIO.InjectOptions{Priority: names[lv]})
|
||||
}
|
||||
|
||||
// 排空:200 个任务必须全部到达终态,且四容器全空。
|
||||
snap := waitQuiescent(t, a, nQueued+nInterrupts, 90*time.Second)
|
||||
|
||||
// ---- 不丢不重 ----
|
||||
if snap.Stats.Executed != nQueued+nInterrupts {
|
||||
t.Fatalf("Executed=%d,期望 %d(每个任务恰好一个终态)",
|
||||
snap.Stats.Executed, nQueued+nInterrupts)
|
||||
}
|
||||
if snap.Stats.Rejected != 0 {
|
||||
t.Fatalf("容量充足却出现 Rejected=%d(背压/深度判定有误)", snap.Stats.Rejected)
|
||||
}
|
||||
wantPerLevel := uint64(nInterrupts / len(levels))
|
||||
for _, lv := range levels {
|
||||
if got := snap.Stats.InterruptsByLevel[lv]; got != wantPerLevel {
|
||||
t.Fatalf("%s 登记数=%d,期望 %d", names[lv], got, wantPerLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 抢占确实发生在**每一级**上 ----
|
||||
if snap.Stats.Suspended == 0 {
|
||||
t.Fatalf("100 条中断没有造成任何抢占:%+v", snap.Stats)
|
||||
}
|
||||
for _, lv := range levels {
|
||||
if got := snap.Stats.PreemptsByLevel[lv]; got == 0 {
|
||||
t.Fatalf("%s 一次都没抢断成功(分级计数=%v)", names[lv], snap.Stats.PreemptsByLevel)
|
||||
}
|
||||
}
|
||||
// 每一次“取消流式段”都必须换来一次挂起(取消→stepLLM 以 Canceled 收尾→安全点让位)。
|
||||
// 反向不成立:挂起也可能发生在别的步骤边界上(那时 LLM 已经成功返回、来不及取消)。
|
||||
if cancelled := uint64(sp.cancelled.Load()); snap.Stats.Suspended < cancelled {
|
||||
t.Fatalf("被取消的 LLM 调用=%d 但有 %d 次挂起:有取消没换来挂起(现场丢了?)",
|
||||
cancelled, snap.Stats.Suspended)
|
||||
}
|
||||
// 排空后挂起必须等于恢复——挂起来的任务都被接回去了。
|
||||
if snap.Stats.Suspended != snap.Stats.Resumed {
|
||||
t.Fatalf("Suspended=%d Resumed=%d,排空后必须相等(否则有现场丢了)",
|
||||
snap.Stats.Suspended, snap.Stats.Resumed)
|
||||
}
|
||||
if snap.Stats.Suspended > nInterrupts {
|
||||
t.Fatalf("挂起次数=%d 超过中断总数 %d(不该有任务被反复挂起这么多次)",
|
||||
snap.Stats.Suspended, nInterrupts)
|
||||
}
|
||||
|
||||
// ---- LLM 调用次数:每个任务至少一次;被抢断的任务重发会增加 ----
|
||||
if got := sp.n.Load(); got < int64(nQueued+nInterrupts) {
|
||||
t.Fatalf("LLM 调用=%d,少于任务数 %d(有任务没跑到 LLM)", got, nQueued+nInterrupts)
|
||||
}
|
||||
|
||||
t.Logf("压力通过:%d 排队 + %d 中断(L1/L2/L3/L4 各 %d)",
|
||||
nQueued, nInterrupts, nInterrupts/len(levels))
|
||||
t.Logf(" Executed=%d Rejected=%d Suspended=%d Resumed=%d LLM完成=%d LLM被取消=%d",
|
||||
snap.Stats.Executed, snap.Stats.Rejected, snap.Stats.Suspended, snap.Stats.Resumed,
|
||||
sp.n.Load(), sp.cancelled.Load())
|
||||
t.Logf(" 登记分级=%v 抢断分级=%v",
|
||||
snap.Stats.InterruptsByLevel, snap.Stats.PreemptsByLevel)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase B:把嵌套压到**结构上限**——排队(L0) ← L1 ← L2 ← L3 ← L4(运行中) = 4 帧。
|
||||
//
|
||||
// Phase A 的形状(100+100 混合)里,中断按严格优先级排队,同一时刻通常只有一层
|
||||
// 嵌套;真正难的是「中断被中断」逐级下潜。这里按级别**逐级**注入:每一级都等到
|
||||
// 上一级正在运行才注入,于是必然层层挂起,直到 L4 之上没有更高级别为止。
|
||||
//
|
||||
// 然后再验证恢复是**严格 LIFO**:L3 → L2 → L1 → 排队任务。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func waitForRunning(a *Agent, pred func(*Task) bool, wait time.Duration) *Task {
|
||||
deadline := time.Now().Add(wait)
|
||||
for time.Now().Before(deadline) {
|
||||
if r := a.DumpScheduler().Running; r != nil && pred(r) {
|
||||
return r
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
|
||||
const llmDelay = 300 * time.Millisecond // 窗口要够宽,让每次 cancel 都来得及生效
|
||||
|
||||
plugin.RegisterFactory("stress_nest_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
sp := &fixedDelayProvider{delay: llmDelay}
|
||||
bus := newLevelRecorder()
|
||||
a := New(AgentConfig{
|
||||
ID: "stress-nest",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
EventBus: bus.bus,
|
||||
PluginReg: plugin.NewRegistry(),
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
|
||||
// 放一个排队任务(无级别)当栈底。
|
||||
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "nest-base"})
|
||||
if r := waitForRunning(a, func(tk *Task) bool { return tk.Class == TaskQueued }, 5*time.Second); r == nil {
|
||||
t.Fatal("排队任务未开始运行")
|
||||
}
|
||||
|
||||
// 逐级下潜:L1 → L2 → L3 → L4(L4 必须来自内核级来源)。
|
||||
type step struct {
|
||||
lv Level
|
||||
src string
|
||||
run Level // 注入前必须在运行的级别
|
||||
}
|
||||
steps := []step{
|
||||
{LevelBackground, "cli", 0}, // 受害者=排队任务
|
||||
{LevelMessage, "cli", LevelBackground}, // 受害者=L1
|
||||
{LevelInteractive, "cli", LevelMessage}, // 受害者=L2
|
||||
{LevelCritical, "stress_nest_builtin", LevelInteractive}, // 受害者=L3
|
||||
}
|
||||
for i, st := range steps {
|
||||
if waitForRunning(a, func(tk *Task) bool {
|
||||
if st.lv == LevelBackground {
|
||||
return tk.Class == TaskQueued
|
||||
}
|
||||
return tk.Class == TaskInterrupt && tk.Level == st.run
|
||||
}, 5*time.Second) == nil {
|
||||
t.Fatalf("第 %d 级(%v)注入前未等到预期的受害者运行", i+1, st.lv)
|
||||
}
|
||||
a.io.InjectInterruptTextOpts(st.src, "cli", fmt.Sprintf("nest-%d", i+1),
|
||||
agentIO.InjectOptions{Priority: map[Level]string{
|
||||
LevelBackground: "L1", LevelMessage: "L2",
|
||||
LevelInteractive: "L3", LevelCritical: "L4",
|
||||
}[st.lv]})
|
||||
|
||||
// 等这一层真的压进栈(否则下一级的"受害者"条件会被误判)。
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if len(a.DumpScheduler().SuspendStack) >= i+1 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("第 %d 级注入后栈深未达 %d:%d",
|
||||
i+1, i+1, len(a.DumpScheduler().SuspendStack))
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// 结构上限:4 帧 = 排队(L0) + L1 + L2 + L3 挂起,L4 运行中。
|
||||
if n := len(a.DumpScheduler().SuspendStack); n != 4 {
|
||||
t.Fatalf("嵌套峰值栈深=%d,期望 4(结构上限)", n)
|
||||
}
|
||||
if a.sched.canSuspend() {
|
||||
t.Fatal("已到结构上限,L4 之上不该再有下潜余量")
|
||||
}
|
||||
|
||||
// 排空:4 层必须逐层弹回,且顺序严格 LIFO。
|
||||
snap := waitQuiescent(t, a, 5, 30*time.Second)
|
||||
if n := len(snap.SuspendStack); n != 0 {
|
||||
t.Fatalf("排空后中断栈=%d,期望 0", n)
|
||||
}
|
||||
if got, want := bus.resumeLevels(), []int{3, 2, 1, 0}; !equalInts(got, want) {
|
||||
t.Fatalf("恢复顺序=%v,期望 LIFO %v", got, want)
|
||||
}
|
||||
// 峰值栈深也就是本次全部挂起帧数:4。
|
||||
if snap.Stats.Suspended != 4 || snap.Stats.Resumed != 4 {
|
||||
t.Fatalf("Suspended=%d Resumed=%d,期望各 4", snap.Stats.Suspended, snap.Stats.Resumed)
|
||||
}
|
||||
if sp.cancelled.Load() == 0 {
|
||||
t.Fatal("逐级下潜必须靠取消流式段生效,却没有一次 LLM 调用被取消")
|
||||
}
|
||||
t.Logf("嵌套压力通过:栈深峰值=4(结构上限),恢复顺序 LIFO=%v,流式段被取消=%d 次",
|
||||
bus.resumeLevels(), sp.cancelled.Load())
|
||||
}
|
||||
|
||||
// levelRecorder 记录 scheduler 事件的挂起/恢复级别。
|
||||
type levelRecorder struct {
|
||||
bus *events.Bus
|
||||
mu sync.Mutex
|
||||
resume []int
|
||||
}
|
||||
|
||||
func newLevelRecorder() *levelRecorder {
|
||||
rec := &levelRecorder{bus: events.NewBus()}
|
||||
rec.bus.Subscribe(events.EventScheduler, func(e *events.Event) {
|
||||
if fmt.Sprint(e.Payload["action"]) != "resume" {
|
||||
return
|
||||
}
|
||||
lv, _ := e.Payload["level"].(int)
|
||||
rec.mu.Lock()
|
||||
rec.resume = append(rec.resume, lv)
|
||||
rec.mu.Unlock()
|
||||
})
|
||||
return rec
|
||||
}
|
||||
|
||||
func (r *levelRecorder) resumeLevels() []int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]int(nil), r.resume...)
|
||||
}
|
||||
|
||||
func equalInts(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
253
internal/agent/core/scheduler_test.go
Normal file
253
internal/agent/core/scheduler_test.go
Normal file
@ -0,0 +1,253 @@
|
||||
package core
|
||||
|
||||
// M2 验收测试:调度器骨架(就绪队列、选择函数、快照、panic 隔离)。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §11.4(Q1/Q4)与 §11.5(O1/K1)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// 新模型的选择顺序:immediate → 中断队列 L4→L1 → 栈顶(与队头比级别) → 排队 FIFO。
|
||||
func TestScheduler_SelectionOrder(t *testing.T) {
|
||||
s := newScheduler(16)
|
||||
|
||||
// 四条中断队列各放一个,入队顺序与级别相反 —— 验证“按级别扫”而非 FIFO。
|
||||
for _, lv := range []Level{LevelBackground, LevelMessage, LevelInteractive, LevelCritical} {
|
||||
evt, _ := textEvent("qq", "中断")
|
||||
s.registerInterrupt(newInterruptTask(evt, lv))
|
||||
}
|
||||
// 排队任务两条(无级别,FIFO)。
|
||||
s.enqueue(newSelfTask(selfInputMsg{text: "q1"}))
|
||||
s.enqueue(newSelfTask(selfInputMsg{text: "q2"}))
|
||||
|
||||
var order []Level
|
||||
for i := 0; i < 4; i++ {
|
||||
task, _, kind := s.nextRef()
|
||||
if kind != nextInterrupt {
|
||||
t.Fatalf("第 %d 个应来自中断队列,kind=%v", i+1, kind)
|
||||
}
|
||||
order = append(order, task.Level)
|
||||
s.done(task)
|
||||
}
|
||||
want := []Level{LevelCritical, LevelInteractive, LevelMessage, LevelBackground}
|
||||
for i := range want {
|
||||
if order[i] != want[i] {
|
||||
t.Fatalf("中断执行顺序=%v,期望 %v", order, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 中断耗尽后才是排队任务,且保持 FIFO。
|
||||
for i := 1; i <= 2; i++ {
|
||||
task, _, kind := s.nextRef()
|
||||
if kind != nextReady {
|
||||
t.Fatalf("中断耗尽后应取排队任务,kind=%v", kind)
|
||||
}
|
||||
if task.Self.text != fmt.Sprintf("q%d", i) {
|
||||
t.Fatalf("排队任务应 FIFO,第 %d 个=%q", i, task.Self.text)
|
||||
}
|
||||
s.done(task)
|
||||
}
|
||||
if _, _, kind := s.nextRef(); kind != nextNone {
|
||||
t.Fatal("全空后应返回 nextNone")
|
||||
}
|
||||
}
|
||||
|
||||
// immediate(刚抢占成功的中断)必须最先运行——哪怕队列里有更高级别的待处理中断。
|
||||
// 这是“抢占立即生效”的实现方式,也是它不需要和栈顶比级别的原因。
|
||||
func TestScheduler_ImmediateWins(t *testing.T) {
|
||||
s := newScheduler(16)
|
||||
evt1, _ := textEvent("cli", "L4 待处理")
|
||||
s.registerInterrupt(newKernelInterruptTask(evt1))
|
||||
|
||||
evt2, _ := textEvent("qq", "抢占者")
|
||||
preemptor := newInterruptTask(evt2, LevelBackground)
|
||||
s.mu.Lock()
|
||||
s.setImmediateLocked(preemptor)
|
||||
s.mu.Unlock()
|
||||
|
||||
task, _, kind := s.nextRef()
|
||||
if kind != nextImmediate || task != preemptor {
|
||||
t.Fatalf("immediate 必须先运行,kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// 中断队列头与中断栈顶比级别,取高者;栈顶是排队任务(无级别)时任何中断都赢。
|
||||
func TestScheduler_StackTopVsInterruptQueue(t *testing.T) {
|
||||
s := newScheduler(16)
|
||||
// 直接构造挂起现场:不走 suspend(),避免 PreemptCount/冷却干扰本用例
|
||||
// (本用例只测“选择顺序”这一件事)。
|
||||
pushSuspended := func(id uint64, class TaskClass, lv Level) {
|
||||
s.mu.Lock()
|
||||
s.suspendStack = append(s.suspendStack, &suspendedTask{
|
||||
Task: &Task{ID: id, Class: class, Level: lv}, Frame: &TaskFrame{},
|
||||
})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
// 每次选取后清掉 running,让下一次 registerInterrupt 不把它当成运行任务。
|
||||
clearRunning := func() {
|
||||
s.mu.Lock()
|
||||
s.running = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// 栈顶 L3,队列只有 L2 → 恢复栈顶。
|
||||
pushSuspended(1, TaskInterrupt, LevelInteractive)
|
||||
evt, _ := textEvent("qq", "L2 待处理")
|
||||
s.registerInterrupt(newInterruptTask(evt, LevelMessage))
|
||||
if _, _, kind := s.nextRef(); kind != nextSuspended {
|
||||
t.Fatalf("栈顶 L3 > 队头 L2 → 应恢复栈顶,kind=%v", kind)
|
||||
}
|
||||
clearRunning()
|
||||
|
||||
// 栈顶 L3,队列来了 L4 → 队头优先。
|
||||
pushSuspended(2, TaskInterrupt, LevelInteractive)
|
||||
evt2, _ := textEvent("cli", "L4 待处理")
|
||||
s.registerInterrupt(newKernelInterruptTask(evt2))
|
||||
if _, _, kind := s.nextRef(); kind != nextInterrupt {
|
||||
t.Fatalf("队头 L4 > 栈顶 L3 → 应先取中断,kind=%v", kind)
|
||||
}
|
||||
clearRunning()
|
||||
|
||||
// 栈顶是排队任务(无级别)→ 任何中断都赢。
|
||||
pushSuspended(3, TaskQueued, 0)
|
||||
evt3, _ := textEvent("qq", "L1 待处理")
|
||||
s.registerInterrupt(newInterruptTask(evt3, LevelBackground))
|
||||
if _, _, kind := s.nextRef(); kind != nextInterrupt {
|
||||
t.Fatalf("排队栈顶可被任何中断打断,kind=%v", kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Q4:队列有界;满了必须拒绝并计数,而不是静默丢弃或无界增长。
|
||||
func TestScheduler_EnqueueBackpressure(t *testing.T) {
|
||||
s := newScheduler(2)
|
||||
if !s.enqueue(newSelfTask(selfInputMsg{text: "a"})) {
|
||||
t.Fatal("第 1 个任务应入队成功")
|
||||
}
|
||||
if !s.enqueue(newSelfTask(selfInputMsg{text: "b"})) {
|
||||
t.Fatal("第 2 个任务应入队成功")
|
||||
}
|
||||
if s.hasRoom() {
|
||||
t.Fatal("队列已满,hasRoom 应为 false")
|
||||
}
|
||||
if s.enqueue(newSelfTask(selfInputMsg{text: "c"})) {
|
||||
t.Fatal("队列满时第 3 个任务必须被拒绝")
|
||||
}
|
||||
if s.stats.Rejected != 1 {
|
||||
t.Fatalf("Rejected=%d,期望 1", s.stats.Rejected)
|
||||
}
|
||||
if s.stats.Enqueued != 2 {
|
||||
t.Fatalf("Enqueued=%d,期望 2", s.stats.Enqueued)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期:next 置 running 并移出队列;done 清 running 并累加计数。
|
||||
func TestScheduler_Lifecycle(t *testing.T) {
|
||||
s := newScheduler(4)
|
||||
s.enqueue(newSelfTask(selfInputMsg{text: "a"}))
|
||||
s.enqueue(newSelfTask(selfInputMsg{text: "b"}))
|
||||
|
||||
t1 := s.next()
|
||||
if t1 == nil || s.running != t1 {
|
||||
t.Fatal("next 应取出任务并置为 running")
|
||||
}
|
||||
if len(s.queue) != 1 {
|
||||
t.Fatalf("取出后队列长度=%d,期望 1", len(s.queue))
|
||||
}
|
||||
// 队列内不得同时出现 running(O1:三集合互不重叠)。
|
||||
for _, q := range s.queue {
|
||||
if q == t1 {
|
||||
t.Fatal("running 任务不得同时留在就绪队列")
|
||||
}
|
||||
}
|
||||
|
||||
s.done(t1)
|
||||
if s.running != nil {
|
||||
t.Fatal("done 后 running 应为 nil")
|
||||
}
|
||||
if s.stats.Executed != 1 {
|
||||
t.Fatalf("Executed=%d,期望 1", s.stats.Executed)
|
||||
}
|
||||
if s.next() == nil {
|
||||
t.Fatal("队列里还有 b,next 不应为 nil")
|
||||
}
|
||||
if s.next() != nil {
|
||||
t.Fatal("队列已空,next 应返回 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// K1:任务 panic 必须被隔离——调度器统计仍然推进,且不向外抛出。
|
||||
func TestScheduler_PanicIsolationOnExecuteTask(t *testing.T) {
|
||||
sp := &scriptProvider{}
|
||||
a := New(AgentConfig{
|
||||
ID: "sched-panic",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
})
|
||||
|
||||
// Event 为 nil:handleInput 解引用即 panic,用来验证 recover 生效。
|
||||
task := &Task{Kind: TaskKindInput, Level: DefaultLevel, Event: nil}
|
||||
a.executeTask(task) // 若未隔离,这里会 panic 冒泡使测试失败
|
||||
|
||||
if a.sched.stats.Executed != 1 {
|
||||
t.Fatalf("panic 后 Executed=%d,期望 1(任务失败但调度器存活)", a.sched.stats.Executed)
|
||||
}
|
||||
if a.sched.running != nil {
|
||||
t.Fatal("panic 后 running 必须被清空")
|
||||
}
|
||||
}
|
||||
|
||||
// O1 轻量版:快照与内部状态一致,且 running 不出现在 queue 里。
|
||||
func TestScheduler_SnapshotConsistency(t *testing.T) {
|
||||
sp := &scriptProvider{}
|
||||
a := New(AgentConfig{
|
||||
ID: "sched-snap",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
})
|
||||
|
||||
a.sched.enqueue(newSelfTask(selfInputMsg{text: "a"}))
|
||||
a.sched.enqueue(newSelfTask(selfInputMsg{text: "b"}))
|
||||
snap := a.DumpScheduler()
|
||||
if snap.Running != nil {
|
||||
t.Fatal("尚未 next,快照的 running 应为 nil")
|
||||
}
|
||||
if len(snap.Queue) != 2 || snap.Stats.Enqueued != 2 {
|
||||
t.Fatalf("快照不一致:queue=%d enqueued=%d", len(snap.Queue), snap.Stats.Enqueued)
|
||||
}
|
||||
|
||||
r := a.sched.next()
|
||||
a.executeTask(&Task{Kind: TaskKindSelf, Level: DefaultLevel, Self: selfInputMsg{text: "a"}})
|
||||
snap = a.DumpScheduler()
|
||||
if snap.Running != r {
|
||||
t.Fatal("执行完成后 running 应仍指向未 done 的任务")
|
||||
}
|
||||
for _, q := range snap.Queue {
|
||||
if q == r {
|
||||
t.Fatal("快照中 running 与 queue 不得重叠")
|
||||
}
|
||||
}
|
||||
|
||||
// 队列快照必须是副本:改快照不得影响调度器。
|
||||
snap.Queue = append(snap.Queue, &Task{})
|
||||
if len(a.DumpScheduler().Queue) != 1 {
|
||||
t.Fatal("DumpScheduler 必须返回队列副本")
|
||||
}
|
||||
}
|
||||
|
||||
// Level 的字面量是持久化/日志契约,改值必须是有意的。
|
||||
func TestLevelContract(t *testing.T) {
|
||||
if LevelBackground != 1 || LevelMessage != 2 || LevelInteractive != 3 || LevelCritical != 4 {
|
||||
t.Fatalf("四级取值被改动:%d/%d/%d/%d",
|
||||
LevelBackground, LevelMessage, LevelInteractive, LevelCritical)
|
||||
}
|
||||
if DefaultLevel != LevelBackground {
|
||||
t.Fatalf("默认级必须是 L1(显式才是特权),实际 %v", DefaultLevel)
|
||||
}
|
||||
}
|
||||
@ -45,7 +45,7 @@ func (a *Agent) evictChildTasksLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string {
|
||||
task, _ := tc.Arguments["task"].(string)
|
||||
if task == "" {
|
||||
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
|
||||
@ -69,9 +69,8 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||
taskID := fmt.Sprintf("child_%d", a.childNextID)
|
||||
a.childMu.Unlock()
|
||||
|
||||
// 捕获父 Agent 当前输出通道:子任务完成通知需回到发起对话的通道,
|
||||
// 让父 Agent 正常感知并可回复用户(而非走无记忆整理路径丢失通知)。
|
||||
parentChannel := a.currentOutputChannel
|
||||
// parentChannel 由调用方(任务帧)传入:子任务完成通知要回到**发起这次
|
||||
// spawn 的那个任务**的通道,而不是"内核当前通道"(那个概念已删除)。
|
||||
if parentChannel == "" || parentChannel == channelConsolidation {
|
||||
parentChannel = "cli"
|
||||
}
|
||||
@ -150,7 +149,7 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
|
||||
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
||||
default:
|
||||
result = a.executeToolCall(ct)
|
||||
result = a.executeToolCall(ct, parentChannel)
|
||||
}
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
|
||||
@ -249,5 +248,3 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -12,9 +12,14 @@ import (
|
||||
)
|
||||
|
||||
func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
|
||||
// 通道从 stage ctx 上取(由发起方写入)——内核不持有"当前通道"。
|
||||
ch := ""
|
||||
if ctx != nil && ctx.Extra != nil {
|
||||
ch, _ = ctx.Extra["output_channel"].(string)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"phase": string(stage),
|
||||
"channel": a.currentOutputChannel,
|
||||
"channel": ch,
|
||||
}
|
||||
if ctx != nil && len(ctx.ToolCalls) > 0 {
|
||||
payload["tool"] = ctx.ToolCalls[0].Name
|
||||
|
||||
@ -156,7 +156,6 @@ func collectKernelStatus(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Tracker
|
||||
if trk != nil {
|
||||
status.Tracker.Available = true
|
||||
@ -192,12 +191,19 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
socialStore = a.social
|
||||
}
|
||||
|
||||
|
||||
var trk *tracker.Tracker
|
||||
if a.tracker != nil {
|
||||
trk = a.tracker
|
||||
}
|
||||
|
||||
// 注意:knowledge 在 collectKernelStatus 里是**接口**参数,
|
||||
// 而 (*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
|
||||
// 所以这里必须先判具体指针再进行接口赋值(healthcheck_kernel 会走到这条路径)。
|
||||
var knowledgeLister interface{ List() []string }
|
||||
if a.knowledge != nil {
|
||||
knowledgeLister = a.knowledge
|
||||
}
|
||||
|
||||
ks := collectKernelStatus(
|
||||
a.startTime,
|
||||
string(a.id),
|
||||
@ -207,15 +213,44 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
a.io,
|
||||
a.pluginReg,
|
||||
a.memory,
|
||||
a.knowledge,
|
||||
knowledgeLister,
|
||||
a.docStore,
|
||||
textMem,
|
||||
socialStore,
|
||||
trk,
|
||||
)
|
||||
ks.ONNX = a.onnxStatus()
|
||||
ks.Scheduler = a.schedulerStatus()
|
||||
|
||||
return ks
|
||||
}
|
||||
|
||||
// onnxStatus 汇总统一多模态向量空间(ONNX 模型)的启用状态。
|
||||
//
|
||||
// 判据是 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」——
|
||||
// 后者在模型缺失 / 运行时缺失时也为真,拿它当判据就是假绿。
|
||||
func (a *Agent) onnxStatus() sdk.ONNXStatus {
|
||||
st := sdk.ONNXStatus{Provider: a.embeddingProvider}
|
||||
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() {
|
||||
st.Enabled = true
|
||||
st.Dim = a.multimodalSpace.Dim()
|
||||
st.Fingerprint = a.multimodalSpace.Fingerprint()
|
||||
// 模态是可选能力:只有底层 provider 报出来时才带出。
|
||||
if mr, ok := a.multimodalSpace.(interface{ Modalities() []string }); ok {
|
||||
st.Modalities = mr.Modalities()
|
||||
}
|
||||
return st
|
||||
}
|
||||
switch {
|
||||
case a.embeddingError != "":
|
||||
st.Reason = "打开失败: " + a.embeddingError
|
||||
case a.embeddingProvider == "":
|
||||
st.Reason = "未配置统一向量空间 provider(走词嵌入/TF-IDF 回退路径)"
|
||||
default:
|
||||
st.Reason = "provider 未加载"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
var _ StatusProvider = (*Agent)(nil)
|
||||
var _ sdk.StatusAPI = (*Agent)(nil)
|
||||
|
||||
103
internal/agent/core/status_onnx_test.go
Normal file
103
internal/agent/core/status_onnx_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
|
||||
// statusSpace 是带模态元数据的假统一空间(ProviderAdapter 的 Modalities() 形状)。
|
||||
type statusSpace struct{ fakeSpace }
|
||||
|
||||
func (statusSpace) Modalities() []string { return []string{"text", "image"} }
|
||||
|
||||
// statusSpaceUnloaded 模拟「provider 建了但没加载成功」。
|
||||
type statusSpaceUnloaded struct{ fakeSpace }
|
||||
|
||||
func (statusSpaceUnloaded) Loaded() bool { return false }
|
||||
|
||||
// healthcheck_kernel 必须能回答两件事:内核版本号、以及**是否真的启用了 ONNX 模型**。
|
||||
//
|
||||
// 判据要点:只有 provider 真正 Loaded() 才算启用 ——「配置里写了 provider」不算,
|
||||
// 否则模型缺失/运行时缺失时会报成已启用(假绿)。
|
||||
func TestKernelStatusReportsVersionAndONNX(t *testing.T) {
|
||||
t.Run("已启用:带出 provider/维度/指纹/模态", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
multimodalSpace: statusSpace{},
|
||||
embeddingProvider: "chineseclip",
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if !st.ONNX.Enabled {
|
||||
t.Fatal("Loaded() 为真时 onnx.enabled 必须为真")
|
||||
}
|
||||
if st.ONNX.Provider != "chineseclip" || st.ONNX.Dim != 2 || st.ONNX.Fingerprint != "fake-space" {
|
||||
t.Fatalf("onnx 身份字段不对: %+v", st.ONNX)
|
||||
}
|
||||
if len(st.ONNX.Modalities) != 2 || st.ONNX.Reason != "" {
|
||||
t.Fatalf("模态/原因不对: %+v", st.ONNX)
|
||||
}
|
||||
// 内核版本号必须随状态一起报:人格卡要求「版本以运行时快照为准」靠的就是这一项
|
||||
if st.Build.Version == "" || st.Build.KernelName == "" {
|
||||
t.Fatalf("build 段缺少版本/内核名: %+v", st.Build)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("打开失败:enabled=false 且给出具体原因", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
embeddingProvider: "chineseclip",
|
||||
embeddingError: `embedding: open provider "chineseclip": model dir missing`,
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled {
|
||||
t.Fatal("打开失败时不能报 enabled")
|
||||
}
|
||||
if st.ONNX.Provider != "chineseclip" {
|
||||
t.Fatalf("未启用时仍应带出配置的 provider: %+v", st.ONNX)
|
||||
}
|
||||
if !strings.Contains(st.ONNX.Reason, "model dir missing") {
|
||||
t.Fatalf("原因应包含具体错误: %q", st.ONNX.Reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未配置:说明会走回退路径", func(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled || st.ONNX.Reason == "" {
|
||||
t.Fatalf("未配置时应 enabled=false 且有原因: %+v", st.ONNX)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provider 存在但未加载", func(t *testing.T) {
|
||||
a := &Agent{
|
||||
io: agentIO.NewIOManager(),
|
||||
multimodalSpace: statusSpaceUnloaded{},
|
||||
embeddingProvider: "qwen3vl",
|
||||
}
|
||||
st := a.GetKernelStatus()
|
||||
if st.ONNX.Enabled {
|
||||
t.Fatal("Loaded() 为假时不能报 enabled")
|
||||
}
|
||||
if st.ONNX.Reason == "" {
|
||||
t.Fatalf("应给出未加载的原因: %+v", st.ONNX)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// collectKernelStatus 的 knowledge 参数是**接口**类型,而 (*knowledge.Store)(nil)
|
||||
// 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
|
||||
// 这条测试钉住这个成因:一旦不再 panic,说明参数形状变了,
|
||||
// GetKernelStatus 里的 typed-nil 守卫就该同步删掉(否则它变成无意义代码)。
|
||||
func TestCollectKernelStatusTypedNilKnowledgePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("typed-nil 交给接口参数却未 panic:成因已变,请更新守卫与本测试")
|
||||
}
|
||||
}()
|
||||
var nilStore *knowledge.Store
|
||||
_ = collectKernelStatus(time.Now(), "a", "", 0, nil, nil, nil, nil, nilStore, nil, nil, nil, nil)
|
||||
}
|
||||
@ -26,7 +26,7 @@ func TestAccumulateStreamToolCalls(t *testing.T) {
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
resp, err := accumulateStream(context.Background(), ch, nil)
|
||||
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
|
||||
if err != nil {
|
||||
t.Fatalf("accumulateStream: %v", err)
|
||||
}
|
||||
@ -56,7 +56,7 @@ func TestAccumulateStreamContent(t *testing.T) {
|
||||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"}
|
||||
close(ch)
|
||||
}()
|
||||
resp, err := accumulateStream(context.Background(), ch, nil)
|
||||
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
|
||||
if err != nil {
|
||||
t.Fatalf("accumulateStream: %v", err)
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) {
|
||||
}
|
||||
close(ch)
|
||||
|
||||
resp, err := accumulateStream(ctx, ch, nil)
|
||||
resp, err := accumulateStream(ctx, ch, nil, "cli")
|
||||
if err != nil {
|
||||
t.Fatalf("accumulateStream: %v", err)
|
||||
}
|
||||
|
||||
934
internal/agent/core/task.go
Normal file
934
internal/agent/core/task.go
Normal file
@ -0,0 +1,934 @@
|
||||
package core
|
||||
|
||||
// 任务状态机(M1:行为等价的纯重构)。
|
||||
//
|
||||
// 背景与设计见 docs/zh/input-scheduler-design.md。
|
||||
//
|
||||
// M1 只做一件事:把原先「一个 425 行的 process() 大函数」拆成
|
||||
// **显式 step 游标 + TaskFrame**。目的不是加能力,而是让「现场」变成数据——
|
||||
// 之后 M3 才能把帧存进 suspendStack 并在安全点恢复。
|
||||
//
|
||||
// 行为等价的判据:既有全部 agent 测试通过,且 R3/X3(见设计文档 §11)通过。
|
||||
//
|
||||
// 本文件**不引入**优先级、抢占、队列与并发;那些在 M2 起逐层加上。
|
||||
//
|
||||
// Step 与安全点(设计文档 §4.2):
|
||||
// step 与 step 之间是安全点;StepToolExec(工具执行)与 StepPrepare 中的
|
||||
// ONNX/落盘片段是**临界区**,执行中不可抢占。
|
||||
//
|
||||
// 与设计文档的差异:文档里的 StepBeforeOutput / StepAfterOutput / StepCommit /
|
||||
// StepFinish 属于 emitResponse 与 processInput 层,M1 不动它们(M6 再迁入帧)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// Step 是任务状态机的游标。
|
||||
type Step int
|
||||
|
||||
const (
|
||||
// StepPrepare 构建消息与工具表、应用中断标记、跑 pre_action 阶段。
|
||||
StepPrepare Step = iota
|
||||
// StepLLM 轮次顶部(中断/占位)+ LLM 调用(含 provider 回退与重试)+ post_action。
|
||||
StepLLM
|
||||
// StepToolBegin 取本批下一个工具,跑 before_toolcall;被拒/插件不健康则跳过。
|
||||
StepToolBegin
|
||||
// StepToolExec 执行工具。**临界区**:副作用不可回滚,执行中不是安全点。
|
||||
StepToolExec
|
||||
// StepToolAfter after_toolcall 阶段、上下文裁剪、消息与事件组装、批后中断检查。
|
||||
StepToolAfter
|
||||
// StepTurnEnd 收尾本批并进入下一轮。
|
||||
StepTurnEnd
|
||||
)
|
||||
|
||||
// stepOutcome 是一次 step 执行的结果。
|
||||
type stepOutcome int
|
||||
|
||||
const (
|
||||
// outcomeContinue 继续执行下一个 step(游标可能停在原地以表达"重跑本 step")。
|
||||
outcomeContinue stepOutcome = iota
|
||||
// outcomeDone 任务成功结束,响应在 frame.Response。
|
||||
outcomeDone
|
||||
// outcomeFailed 任务失败结束,错误在 frame.Err。
|
||||
outcomeFailed
|
||||
// outcomeSuspended 任务在安全点被抢占挂起,帧已保存(M3b 起使用)。
|
||||
outcomeSuspended
|
||||
)
|
||||
|
||||
// taskTerminal 是任务的终态种类(设计文档 §7:每个任务恰有一个终态)。
|
||||
type taskTerminal int
|
||||
|
||||
const (
|
||||
// terminalNone 任务尚未结束,需进入 run 段。
|
||||
terminalNone taskTerminal = iota
|
||||
// terminalOK 正常完成(已提交上下文并回执)。
|
||||
terminalOK
|
||||
// terminalError 执行出错(已提交错误响应)。
|
||||
terminalError
|
||||
// terminalStageShortCircuit 被 on_input 阶段短路(响应已发出)。
|
||||
terminalStageShortCircuit
|
||||
// terminalSkipped 未进入执行:解析失败或被去重。
|
||||
terminalSkipped
|
||||
// terminalConsolidation 走记忆整理专用路径,已处理完毕。
|
||||
terminalConsolidation
|
||||
// terminalSuspended 被抢占挂起,等待恢复(M3b 起使用)。
|
||||
terminalSuspended
|
||||
)
|
||||
|
||||
// TaskFrame 承载一个任务在安全点之间必须存活的所有状态。
|
||||
//
|
||||
// 不变量(设计文档 §8.1 I3):帧是**纯数据**;不得持有任何锁或资源跨越安全点。
|
||||
type TaskFrame struct {
|
||||
Input string
|
||||
StageCtx *sdk.StageContext
|
||||
|
||||
// 跨轮次状态
|
||||
Msgs []agentAPI.Message
|
||||
Tools []interface{}
|
||||
ToolsUsed []string
|
||||
ToolResults []ToolResultItem
|
||||
Turn int
|
||||
LastBatchReplyOnly bool
|
||||
|
||||
// 当前工具批
|
||||
PendingTools []agentAPI.ToolCall
|
||||
ToolIdx int
|
||||
ReplyOnly bool
|
||||
ContentOnce bool
|
||||
CurTool agentAPI.ToolCall
|
||||
CurToolPlugin string
|
||||
CurResult string
|
||||
Resp *agentAPI.CompletionResponse
|
||||
|
||||
// 游标与终态
|
||||
Step Step
|
||||
Response string
|
||||
Err error
|
||||
|
||||
// ---- 任务层现场(原 processInput 的局部变量)----
|
||||
//
|
||||
// 这些字段让帧覆盖 prepare → step… → finish 全生命周期:挂起发生在 run 段的
|
||||
// 安全点,恢复后由 finish 段统一提交(context.Append + emitResponse +
|
||||
// emitMemoryCandidate),因此挂起不会重复提交。
|
||||
Evt *agentIO.InputEvent
|
||||
CleanInput string
|
||||
IsInterrupt bool
|
||||
StartedAt time.Time
|
||||
Terminal taskTerminal
|
||||
// OutputChannel 是本任务的输出通道(来源通道的稳定副本)。
|
||||
//
|
||||
// 这是本任务通道的**唯一**来源:内核不持有"当前通道"可变状态(N0 已删除
|
||||
// Agent.currentOutputChannel)。那类字段会被后来的任务覆盖,而被打断任务
|
||||
// 恢复时不重新 prepare(resumeTask 只 rebase 前缀),于是两任务串台——
|
||||
// 被打断任务的回复发到中断任务的通道上(见
|
||||
// TestPreempt_ResumeKeepsOwnOutputChannel)。
|
||||
OutputChannel string
|
||||
|
||||
// PrefixLen 是 stepPrepare 构建的**基础前缀**长度(system + timeline + 用户输入)。
|
||||
// 恢复时用它把「本任务自己的现场」接回重建后的前缀之上(见 rebaseFramePrefix)。
|
||||
PrefixLen int
|
||||
// InputBlocks 是本轮输入携带的多模态块;重建前缀时要重新挂回。
|
||||
InputBlocks []agentAPI.ContentBlock
|
||||
}
|
||||
|
||||
// outputChannelOf 从**输入事件**推导本次输出应走的通道。
|
||||
//
|
||||
// 内核不持有"当前通道"可变状态:那类字段会被后来的任务(中断任务)覆盖,
|
||||
// 使被打断任务恢复后的提示词/事件标签串台。通道只跟着事件与帧走。
|
||||
func outputChannelOf(evt *agentIO.InputEvent) string {
|
||||
if evt == nil {
|
||||
return ""
|
||||
}
|
||||
if evt.OutputChannel != "" {
|
||||
return evt.OutputChannel
|
||||
}
|
||||
return evt.Source
|
||||
}
|
||||
|
||||
// isCriticalChannel 报告某个通道是否是**整任务不可抢占**的临界区。
|
||||
//
|
||||
// 目前只有 `_consolidation_`(记忆整理直接改图库)。工具执行/ONNX/CAS 属于
|
||||
// **单步**临界区,由"只在 step 之间检查让位"天然保护,不在这里列。
|
||||
func isCriticalChannel(channel string) bool {
|
||||
return channel == channelConsolidation
|
||||
}
|
||||
|
||||
func (a *Agent) newTaskFrame(input string, stageCtx *sdk.StageContext) *TaskFrame {
|
||||
return &TaskFrame{Input: input, StageCtx: stageCtx, Step: StepPrepare}
|
||||
}
|
||||
|
||||
// runTaskSteps 驱动状态机直到任务结束或被抢占挂起。
|
||||
//
|
||||
// 这是 M1 的驱动循环,M3a 从 process() 抽出来,使调用方可以拿到
|
||||
// outcomeSuspended 并把帧留给调度器保存。
|
||||
func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
|
||||
// 步数上限只是防"转移缺失导致死循环"的护栏;正常任务远达不到。
|
||||
const maxSteps = 1 << 20
|
||||
for i := 0; i < maxSteps; i++ {
|
||||
// 安全点:只在 step 之间检查让位。临界区(StepToolExec)不在此列,
|
||||
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
|
||||
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
|
||||
return outcomeSuspended
|
||||
}
|
||||
// 工具轮次硬上限(设计文档 D6):在发起下一轮 LLM 前收尾。
|
||||
// f.Turn 只在 stepTurnEnd 递增,所以它等于「已完成的工具批数」;
|
||||
// 因此这里允许 maxToolTurns 批,而不会多跑第 maxToolTurns+1 轮。
|
||||
if f.Step == StepLLM && a.maxToolTurns > 0 && f.Turn >= a.maxToolTurns {
|
||||
log.Printf("[agent] 已达最大工具轮次 %d(turn=%d),强制收尾", a.maxToolTurns, f.Turn)
|
||||
if f.Resp != nil && strings.TrimSpace(f.Resp.Content) != "" {
|
||||
f.Response = f.Resp.Content
|
||||
} else {
|
||||
f.Response = fmt.Sprintf("[系统] 已达到最大工具轮次 %d,任务中止。", a.maxToolTurns)
|
||||
}
|
||||
return outcomeDone
|
||||
}
|
||||
switch a.step(f) {
|
||||
case outcomeDone:
|
||||
return outcomeDone
|
||||
case outcomeFailed:
|
||||
return outcomeFailed
|
||||
case outcomeSuspended:
|
||||
return outcomeSuspended
|
||||
}
|
||||
}
|
||||
f.Err = fmt.Errorf("agent: task step budget exhausted(状态机未收敛,疑似转移缺失)")
|
||||
return outcomeFailed
|
||||
}
|
||||
|
||||
// process 是保留给 processConsolidation 与测试的薄壳,返回与原实现相同的四元组。
|
||||
//
|
||||
// 注意:M3a 起**不再持 a.mu**——调度器是唯一执行者,而挂起不能持锁。
|
||||
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
|
||||
if a.provider == nil {
|
||||
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
|
||||
}
|
||||
f := a.newTaskFrame(input, stageCtx)
|
||||
switch a.runTaskSteps(f) {
|
||||
case outcomeDone:
|
||||
return f.Response, f.ToolsUsed, f.ToolResults, nil
|
||||
case outcomeFailed:
|
||||
return "", f.ToolsUsed, f.ToolResults, f.Err
|
||||
default:
|
||||
// 不该发生:process() 不参与挂起(只有 runInputTask 会)。
|
||||
return "", f.ToolsUsed, f.ToolResults,
|
||||
fmt.Errorf("agent: task suspended outside scheduler")
|
||||
}
|
||||
}
|
||||
|
||||
// runInputTask 是一个输入任务的完整生命周期:prepare → run → finish。
|
||||
//
|
||||
// 它是原 processInput 的全部职责,被拆成三段而不是一个大函数,目的只有一个:
|
||||
// 让帧可以跨安全点被挂起——挂起后由调度器保存,恢复时接着 run 段继续,
|
||||
// 而 finish 段(上下文提交与回执)只在任务真正结束时执行一次。
|
||||
//
|
||||
// M3a 还没有抢占,因此 outcomeSuspended 只会由 M3b 的抢占检查产生。
|
||||
func (a *Agent) runInputTask(evt *agentIO.InputEvent) (*TaskFrame, stepOutcome) {
|
||||
// 临界区标记由调度器 goroutine 维护,任务结束(含挂起)即清。
|
||||
// interceptLoop 读它来决定“能不能取消”,因此必须是原子的。
|
||||
defer a.sched.setCritical(false)
|
||||
|
||||
f, term := a.prepareInputTask(evt)
|
||||
switch term {
|
||||
case terminalSkipped, terminalStageShortCircuit, terminalConsolidation:
|
||||
return nil, outcomeDone
|
||||
}
|
||||
|
||||
out := a.runTaskSteps(f)
|
||||
if out == outcomeSuspended {
|
||||
f.Terminal = terminalSuspended
|
||||
return f, outcomeSuspended
|
||||
}
|
||||
a.finishInputTask(f, out)
|
||||
return f, out
|
||||
}
|
||||
|
||||
// rebaseFramePrefix 把被挂起任务的上下文现场「加载回中断任务之上」。
|
||||
//
|
||||
// 语义(用户明确):
|
||||
// - 中断打断时,被挂起任务自到达以来累积的全部现场(含 toolcall)被保护;
|
||||
// - 中断在上一个任务之前的**完整状态**上开始运行(所以中断看不到本任务的部分进展);
|
||||
// - 中断结束后,把被挂起任务与其现场加载回中断任务**之上**再继续——
|
||||
// 即中断已提交的那段上下文留在下面(前缀),本任务自己的现场落回其上。
|
||||
//
|
||||
// 实现:重建基础前缀(system + timeline + 用户输入);由于中断结束时已把它的
|
||||
// 输入/输出提交进 a.context,重建出的 timeline 已含中断的效果;再把本任务
|
||||
// 自己的尾部(Stage 上下文 + 工具轮产物 + 占位)原样接回。
|
||||
func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
|
||||
if f == nil || f.PrefixLen <= 0 || f.PrefixLen > len(f.Msgs) {
|
||||
return
|
||||
}
|
||||
tail := append([]agentAPI.Message(nil), f.Msgs[f.PrefixLen:]...)
|
||||
|
||||
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
|
||||
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||
prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
|
||||
|
||||
// 重建会丢掉 prepare 段对尾部消息的两处改写,这里等价地补回。
|
||||
if f.IsInterrupt && len(prefix) > 0 {
|
||||
last := prefix[len(prefix)-1]
|
||||
last.Role = "system"
|
||||
last.Content = "[中断消息] " + last.Content
|
||||
prefix[len(prefix)-1] = last
|
||||
}
|
||||
if len(f.InputBlocks) > 0 && len(prefix) > 0 {
|
||||
prefix[len(prefix)-1].Blocks = f.InputBlocks
|
||||
}
|
||||
|
||||
f.Msgs = append(prefix, tail...)
|
||||
f.PrefixLen = len(prefix)
|
||||
}
|
||||
|
||||
// prepareInputTask 执行 processInput 的前半段(去重、通道解析、阶段、裁剪、
|
||||
// 输入事件落上下文)。返回终态不为 terminalNone 时调用方不得进入 run 段。
|
||||
func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTerminal) {
|
||||
start := time.Now()
|
||||
|
||||
in, ok := a.resolveInput(evt)
|
||||
if !ok {
|
||||
// 空输入(文本与媒体都空):没有可处理内容,但同步调用方仍在等回执。
|
||||
a.emitSkippedReply(evt, "empty_input")
|
||||
return nil, terminalSkipped
|
||||
}
|
||||
|
||||
// 去重按文本做:webui/GUI 断线重连会重放未确认消息。
|
||||
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
|
||||
// 是同一句,拿它去重会把连发的两张图误判成重复。
|
||||
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
|
||||
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
|
||||
// 去重是「不处理」而不是「不回」,否则同步调用方(cli/clawhub 无超时)
|
||||
// 会永久挂起(设计文档 §7 不变量 I5、§11.3 X2/X4)。
|
||||
a.emitSkippedReply(evt, "duplicate")
|
||||
return nil, terminalSkipped
|
||||
}
|
||||
|
||||
// 通道只从**输入事件**推导,内核不持有"当前通道"可变状态
|
||||
// (见 outputChannelOf;这消除了中断任务覆盖它导致被打断任务串台的整类问题)。
|
||||
// 进入本任务的临界区属性(记忆整理整任务不可抢占)。
|
||||
// 必须在 processConsolidation 之前设置——它就在下面同步执行。
|
||||
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
|
||||
|
||||
if evt.OutputChannel == channelConsolidation {
|
||||
a.processConsolidation(evt, in.text)
|
||||
return nil, terminalConsolidation
|
||||
}
|
||||
|
||||
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
|
||||
// 原始 data/url,也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
|
||||
//(payload 里才有 data/url);插件注入的是成品 block,取不到原始数据。
|
||||
if evt.Type == "image" || evt.Type == "audio" {
|
||||
a.pendingMedia = evt.Payload
|
||||
}
|
||||
|
||||
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
|
||||
// base64 随 message 数组发给模型后就丢了。
|
||||
if len(in.blocks) > 0 {
|
||||
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
|
||||
}
|
||||
|
||||
noMemory := false
|
||||
if v, ok := evt.Payload["no_memory"].(bool); ok {
|
||||
noMemory = v
|
||||
}
|
||||
if !noMemory && a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
|
||||
noMemory = true
|
||||
}
|
||||
}
|
||||
|
||||
// 工具提醒/中断(terminal_watch、timer 等)不是用户发言:
|
||||
// 以 system 角色注入 LLM,且不写入用户对话履历。
|
||||
isInterrupt, _ := evt.Payload["interrupt"].(bool)
|
||||
a.interruptInput = isInterrupt
|
||||
if isInterrupt {
|
||||
noMemory = true
|
||||
}
|
||||
|
||||
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
|
||||
stageCtx.Extra["input_source"] = evt.Source
|
||||
stageCtx.Extra["output_channel"] = evt.OutputChannel
|
||||
if len(in.blocks) > 0 {
|
||||
stageCtx.Extra["media_blocks"] = in.blocks
|
||||
stageCtx.Extra["media_type"] = in.mediaType
|
||||
}
|
||||
if noMemory {
|
||||
stageCtx.NoMemory = true
|
||||
}
|
||||
a.injectSourceContext(stageCtx, evt)
|
||||
|
||||
if a.runStage(sdk.StageOnInput, stageCtx) {
|
||||
a.emitResponse(evt, *stageCtx.Response)
|
||||
return nil, terminalStageShortCircuit
|
||||
}
|
||||
|
||||
input := stageCtx.RawMessage
|
||||
|
||||
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
|
||||
cleanInput := input
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
||||
cleanInput = chDef.Cleaner(input)
|
||||
}
|
||||
}
|
||||
|
||||
// upload_* 字段一并转发:webui 的 EventRawInput 订阅方靠它们还原附件卡片。
|
||||
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
|
||||
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
|
||||
if v, ok := evt.Payload[k]; ok {
|
||||
rawPayload[k] = v
|
||||
}
|
||||
}
|
||||
a.publishEvent(events.EventRawInput, rawPayload)
|
||||
|
||||
archived := a.pruneOnInput(evt, cleanInput)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
|
||||
// 本轮 inputch(处理表按它记账)+ contextfull 检测(只有驻留子设了钩子)。
|
||||
a.tableMu.Lock()
|
||||
a.currentInputch = outputChannelOf(evt)
|
||||
a.tableMu.Unlock()
|
||||
|
||||
if !isInterrupt {
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
|
||||
f := a.newTaskFrame(input, stageCtx)
|
||||
f.Evt = evt
|
||||
f.CleanInput = cleanInput
|
||||
f.IsInterrupt = isInterrupt
|
||||
f.StartedAt = start
|
||||
// 通道记进帧:恢复时用它把 agent 级字段改回来(见 TaskFrame.OutputChannel)。
|
||||
f.OutputChannel = outputChannelOf(evt)
|
||||
return f, terminalNone
|
||||
}
|
||||
|
||||
// finishInputTask 执行 processInput 的后半段(日志、上下文提交、回执、记忆候选)。
|
||||
//
|
||||
// 只在任务真正结束时调用一次——这正是不变量 I5(每任务恰一次终态)的落点。
|
||||
func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||
evt := f.Evt
|
||||
|
||||
// pendingMedia 是「本轮」语义:任务结束即清(挂起时保留,见 runInputTask)。
|
||||
if evt != nil && (evt.Type == "image" || evt.Type == "audio") {
|
||||
a.pendingMedia = nil
|
||||
}
|
||||
|
||||
if out == outcomeFailed {
|
||||
log.Printf("[agent] process %s error: %v", evt.Type, f.Err)
|
||||
resp := fmt.Sprintf("处理错误: %v", f.Err)
|
||||
a.emitResponse(evt, resp)
|
||||
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: f.Input, Response: resp})
|
||||
f.Terminal = terminalError
|
||||
return
|
||||
}
|
||||
|
||||
// inputch 处理表:本轮**未主动写入**时由系统自动写(保证每轮必有记录)。
|
||||
// 只有驻留子会用到(根 agent 的 children 为 0 时这只是几个空操作)。
|
||||
a.autoRecordInputch(f)
|
||||
|
||||
elapsed := time.Since(f.StartedAt)
|
||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)",
|
||||
evt.Type, evt.Source, elapsed.Milliseconds(), f.ToolsUsed)
|
||||
|
||||
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
|
||||
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的。
|
||||
turnEvt := ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: f.CleanInput,
|
||||
Response: f.Response,
|
||||
ToolsUsed: f.ToolsUsed,
|
||||
ToolResults: f.ToolResults,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
|
||||
a.context.Append(turnEvt)
|
||||
|
||||
a.emitResponse(evt, f.Response)
|
||||
|
||||
if !f.StageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed)
|
||||
}
|
||||
f.Terminal = terminalOK
|
||||
}
|
||||
|
||||
// step 执行恰好一个 step。
|
||||
func (a *Agent) step(f *TaskFrame) stepOutcome {
|
||||
switch f.Step {
|
||||
case StepPrepare:
|
||||
return a.stepPrepare(f)
|
||||
case StepLLM:
|
||||
return a.stepLLM(f)
|
||||
case StepToolBegin:
|
||||
return a.stepToolBegin(f)
|
||||
case StepToolExec:
|
||||
return a.stepToolExec(f)
|
||||
case StepToolAfter:
|
||||
return a.stepToolAfter(f)
|
||||
case StepTurnEnd:
|
||||
return a.stepTurnEnd(f)
|
||||
default:
|
||||
f.Err = fmt.Errorf("agent: unknown task step %d", f.Step)
|
||||
return outcomeFailed
|
||||
}
|
||||
}
|
||||
|
||||
// stepPrepare 构建本轮任务的初始帧。
|
||||
func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
|
||||
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
|
||||
|
||||
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
|
||||
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
|
||||
f.Tools = a.buildToolDefs()
|
||||
|
||||
f.Msgs = a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
|
||||
// 工具提醒(interrupt):以 system 角色注入,不让模型误认为用户发言
|
||||
if a.interruptInput {
|
||||
last := f.Msgs[len(f.Msgs)-1]
|
||||
last.Role = "system"
|
||||
last.Content = "[中断消息] " + last.Content
|
||||
f.Msgs[len(f.Msgs)-1] = last
|
||||
a.interruptInput = false
|
||||
}
|
||||
if blocks, ok := f.StageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
|
||||
if len(f.Msgs) > 0 {
|
||||
f.Msgs[len(f.Msgs)-1].Blocks = blocks
|
||||
}
|
||||
f.InputBlocks = blocks
|
||||
}
|
||||
// 基础前缀到此为止(system + timeline + 用户输入);其后的 Stage 上下文
|
||||
// 与工具轮产物都属于“本任务自己的现场”,恢复时要接回重建后的前缀之上。
|
||||
f.PrefixLen = len(f.Msgs)
|
||||
|
||||
log.Printf("[agent] tool call loop start, max_ctx=%d target=%d fixed=%d mem=%d ctx=%d %d tools, %d events, personality=%t, docs=%d",
|
||||
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
|
||||
len(f.Tools), a.context.Len(),
|
||||
a.personality != nil && a.personality.Content != "",
|
||||
a.docStoreSize())
|
||||
|
||||
if a.runStage(sdk.StagePreAction, f.StageCtx) {
|
||||
f.Response = *f.StageCtx.Response
|
||||
return outcomeDone
|
||||
}
|
||||
if len(f.StageCtx.ContextMsgs) > 0 {
|
||||
for _, m := range f.StageCtx.ContextMsgs {
|
||||
role, _ := m["role"].(string)
|
||||
content, _ := m["content"].(string)
|
||||
if role != "" {
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: role, Content: content})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上下文占满检测:此刻 f.Msgs 已建好(含 system + timeline + 本轮输入)。
|
||||
// 只有驻留子设了 onContextFull ⇒ 对根 agent 是 no-op。
|
||||
a.checkContextFull(f)
|
||||
|
||||
f.Step = StepLLM
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
// stepLLM 是轮次顶部与 LLM 调用。
|
||||
//
|
||||
// 取消(context.Canceled 且 agent 未退出)时**留在本 step 并 Turn++**——等价于
|
||||
// 原实现的 `continue`:重新排空中断、补占位、重新请求。抢占挂起将在 M3 从这里接管。
|
||||
func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
|
||||
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
f.Msgs = dropContinuationPlaceholders(f.Msgs)
|
||||
if last := f.Msgs[len(f.Msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: continuationFor(f.LastBatchReplyOnly),
|
||||
})
|
||||
}
|
||||
|
||||
req := &agentAPI.CompletionRequest{
|
||||
Messages: f.Msgs,
|
||||
MaxTokens: 4096,
|
||||
Tools: f.Tools,
|
||||
ToolChoice: "auto",
|
||||
DisableThinking: !a.thinkingEnabled,
|
||||
}
|
||||
|
||||
providers := a.resolveProviders(req)
|
||||
resp, llmErr := a.callLLMWithFallback(req, providers, f.OutputChannel)
|
||||
|
||||
if llmErr != nil {
|
||||
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
|
||||
if f.OutputChannel == channelConsolidation {
|
||||
f.Err = fmt.Errorf("interrupted by user input")
|
||||
return outcomeFailed
|
||||
}
|
||||
f.Turn++
|
||||
return outcomeContinue // 重跑 StepLLM
|
||||
}
|
||||
f.Err = fmt.Errorf("all %d providers failed, last error: %w", len(providers), llmErr)
|
||||
return outcomeFailed
|
||||
}
|
||||
|
||||
f.StageCtx.LLMText = resp.Content
|
||||
f.StageCtx.ReasoningContent = resp.ReasoningContent
|
||||
f.StageCtx.TokenUsage = map[string]int{
|
||||
"prompt_tokens": resp.TokenUsage.Prompt,
|
||||
"completion_tokens": resp.TokenUsage.Completion,
|
||||
"total_tokens": resp.TokenUsage.Total,
|
||||
}
|
||||
f.StageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
|
||||
for i := range f.StageCtx.ToolCalls {
|
||||
if f.StageCtx.ToolCalls[i].Plugin == "" {
|
||||
f.StageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(f.StageCtx.ToolCalls[i].Name)
|
||||
}
|
||||
}
|
||||
if a.runStage(sdk.StagePostAction, f.StageCtx) {
|
||||
f.Response = *f.StageCtx.Response
|
||||
return outcomeDone
|
||||
}
|
||||
resp.Content = f.StageCtx.LLMText
|
||||
resp.ToolCalls = convertBackToolCalls(f.StageCtx.ToolCalls)
|
||||
|
||||
chainPayload := map[string]interface{}{
|
||||
"content": resp.Content,
|
||||
"reasoning": resp.ReasoningContent,
|
||||
"tool_calls": resp.ToolCalls,
|
||||
"phase": "intermediate",
|
||||
"turn": f.Turn,
|
||||
}
|
||||
if resp.TokenUsage.Total > 0 {
|
||||
chainPayload["usage"] = map[string]int{
|
||||
"prompt": resp.TokenUsage.Prompt,
|
||||
"completion": resp.TokenUsage.Completion,
|
||||
"total": resp.TokenUsage.Total,
|
||||
}
|
||||
}
|
||||
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
||||
|
||||
if resp.ReasoningContent != "" {
|
||||
a.publishEvent(events.EventReasoning, map[string]interface{}{
|
||||
"content": resp.ReasoningContent,
|
||||
"channel": f.OutputChannel,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
f.Response = resp.Content
|
||||
return outcomeDone
|
||||
}
|
||||
|
||||
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
|
||||
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break,放在循环里统计会漏。
|
||||
f.Resp = resp
|
||||
f.ReplyOnly = true
|
||||
for _, tc := range resp.ToolCalls {
|
||||
if !isOutputDeliveryTool(tc.Name) {
|
||||
f.ReplyOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
f.ContentOnce = true
|
||||
f.PendingTools = resp.ToolCalls
|
||||
f.ToolIdx = 0
|
||||
f.Step = StepToolBegin
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
// stepToolBegin 取本批下一个工具;批已耗尽或发生中断则进入收尾。
|
||||
func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||
if f.ToolIdx >= len(f.PendingTools) {
|
||||
f.Step = StepTurnEnd
|
||||
return outcomeContinue
|
||||
}
|
||||
tc := f.PendingTools[f.ToolIdx]
|
||||
|
||||
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
|
||||
pluginName := a.resolveToolPlugin(tc.Name)
|
||||
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
||||
if tc.RawArguments != "" {
|
||||
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
|
||||
}
|
||||
|
||||
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
||||
f.StageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
||||
f.StageCtx.ToolResults = nil
|
||||
if a.runStage(sdk.StageBeforeToolcall, f.StageCtx) {
|
||||
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "denied",
|
||||
"channel": f.OutputChannel,
|
||||
})
|
||||
f.ToolIdx++
|
||||
return outcomeContinue
|
||||
}
|
||||
tc.Arguments = f.StageCtx.ToolCalls[0].Arguments
|
||||
|
||||
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
|
||||
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
|
||||
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
f.ToolIdx++
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
f.CurTool = tc
|
||||
f.CurToolPlugin = pluginName
|
||||
f.Step = StepToolExec
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
|
||||
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
|
||||
result := a.executeToolCall(f.CurTool, f.OutputChannel)
|
||||
f.CurResult = result
|
||||
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
|
||||
log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100))
|
||||
|
||||
f.StageCtx.ToolResults = []sdk.ToolResult{{
|
||||
CallID: f.CurTool.ID, Name: f.CurTool.Name, Plugin: f.CurToolPlugin,
|
||||
Success: true, Result: result,
|
||||
}}
|
||||
f.Step = StepToolAfter
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
// stepToolAfter 是工具执行后的全部后处理(阶段、裁剪、消息与事件)。
|
||||
func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
|
||||
tc := f.CurTool
|
||||
pluginName := f.CurToolPlugin
|
||||
result := f.CurResult
|
||||
|
||||
a.runStage(sdk.StageAfterToolcall, f.StageCtx)
|
||||
if len(f.StageCtx.ToolResults) > 0 {
|
||||
if r, ok := f.StageCtx.ToolResults[0].Result.(string); ok {
|
||||
result = r
|
||||
}
|
||||
}
|
||||
// ContextPolicy: prune 工具调用后执行上下文裁剪(§13.8)
|
||||
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
|
||||
if a.context != nil {
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
// 查询向量取**清洗后**的有效内容,否则噪声(ANSI/base64/JSON 包装)
|
||||
// 会把相关性打分带偏,裁掉本该保留的事件。
|
||||
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
|
||||
}
|
||||
}
|
||||
|
||||
msgContent := ""
|
||||
if f.ContentOnce {
|
||||
msgContent = f.Resp.Content
|
||||
f.ContentOnce = false
|
||||
}
|
||||
f.Msgs = append(f.Msgs, agentAPI.Message{
|
||||
Role: "assistant", Content: msgContent,
|
||||
ReasoningContent: f.Resp.ReasoningContent,
|
||||
ToolCalls: []agentAPI.ToolCall{tc},
|
||||
})
|
||||
|
||||
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
|
||||
//
|
||||
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
|
||||
// 这也是插件文案一直在说的「注入后续对话」。
|
||||
// 为何不能挂 tool message:同一张图、同一模型、三轮实测——
|
||||
// 图在 user message → 3/3 读到
|
||||
// 图在 tool message → 0/3(模型答「没能读到这张图」)
|
||||
// tool 纯文本 + 后接 user → 3/3 读到
|
||||
// tool message 那轮 prompt_tokens 反而更高(7967 vs 7089),base64 确实
|
||||
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
|
||||
//
|
||||
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
|
||||
// 返回 200,模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
|
||||
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
var mediaMsg *agentAPI.Message
|
||||
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
|
||||
var blocks []agentAPI.ContentBlock
|
||||
for _, b := range rawBlocks {
|
||||
if cb, ok := b.(pubsdk.ContentBlock); ok {
|
||||
// 跨包类型拷贝(pubsdk.ContentBlock → agentAPI.ContentBlock)
|
||||
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
|
||||
if cb.ImageURL != nil {
|
||||
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
|
||||
}
|
||||
if cb.AudioURL != nil {
|
||||
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
// 先落进 CAS:无论下面走直视还是回退转写,媒体本体都该进记忆。
|
||||
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
|
||||
|
||||
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
|
||||
// 能直视:另起一条 user message 承载媒体,并补一句来源说明。
|
||||
mediaBlocks := append([]agentAPI.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
|
||||
}}, native...)
|
||||
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
|
||||
} else if fallbackText != "" {
|
||||
toolMsg.Content = result + "\n\n" + fallbackText
|
||||
result = toolMsg.Content
|
||||
if len(f.ToolResults) > 0 {
|
||||
f.ToolResults[len(f.ToolResults)-1].Output = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
f.Msgs = append(f.Msgs, toolMsg)
|
||||
if mediaMsg != nil {
|
||||
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
|
||||
f.Msgs = append(f.Msgs, *mediaMsg)
|
||||
}
|
||||
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"plugin": pluginName,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
"channel": f.OutputChannel,
|
||||
})
|
||||
|
||||
f.ToolIdx++
|
||||
f.Step = StepToolBegin
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
// stepTurnEnd 收尾本批并进入下一轮。
|
||||
func (a *Agent) stepTurnEnd(f *TaskFrame) stepOutcome {
|
||||
// 供下一轮顶部选择补位文案。
|
||||
f.LastBatchReplyOnly = f.ReplyOnly
|
||||
f.Turn++
|
||||
f.Step = StepLLM
|
||||
return outcomeContinue
|
||||
}
|
||||
|
||||
// resolveProviders 按请求模型解析候选 provider(保持原语义)。
|
||||
func (a *Agent) resolveProviders(req *agentAPI.CompletionRequest) []agentAPI.Provider {
|
||||
var providers []agentAPI.Provider
|
||||
if a.providerManager != nil {
|
||||
var allProviders []agentAPI.Provider
|
||||
if req.Model != "" && !strings.EqualFold(req.Model, "AUTO") {
|
||||
allProviders = a.providerManager.ResolveForModel(req.Model)
|
||||
} else {
|
||||
allProviders = a.providerManager.OrderedProviders()
|
||||
}
|
||||
providers = make([]agentAPI.Provider, 0, len(allProviders))
|
||||
for _, p := range allProviders {
|
||||
if a.providerManager.IsAvailable(p.Name()) {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
providers = []agentAPI.Provider{a.provider}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// callLLMWithFallback 在候选 provider 间回退,并把同源瞬时错误重试一次。
|
||||
// 逐行等价于原 process() 内的双层循环。
|
||||
func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers []agentAPI.Provider, channel string) (*agentAPI.CompletionResponse, error) {
|
||||
var resp *agentAPI.CompletionResponse
|
||||
var llmErr error
|
||||
|
||||
for pi, fbProvider := range providers {
|
||||
if pi > 0 {
|
||||
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
|
||||
fbProvider.Name(), pi, len(providers)-1)
|
||||
}
|
||||
|
||||
// 同源瞬时错误重试:网关瞬断(502/503/504/429/网络抖动)通常秒级恢复,
|
||||
// 直接跳下一个 provider(或直接报错)会丢掉本可成功的请求。
|
||||
// 凭证错误(401/403)与用户中断不重试。
|
||||
const maxAttempts = 2
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("[agent] provider %q transient failure, retry %d/%d in 2s: %v",
|
||||
fbProvider.Name(), attempt, maxAttempts, llmErr)
|
||||
select {
|
||||
case <-time.After(2 * time.Second):
|
||||
case <-a.ctx.Done():
|
||||
llmErr = a.ctx.Err()
|
||||
}
|
||||
if llmErr == nil || errors.Is(llmErr, context.Canceled) || errors.Is(llmErr, context.DeadlineExceeded) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fCtx, fCancel := context.WithCancel(a.ctx)
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = fCancel
|
||||
a.llmMu.Unlock()
|
||||
|
||||
resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a, channel)
|
||||
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = nil
|
||||
a.llmMu.Unlock()
|
||||
fCancel()
|
||||
|
||||
if llmErr == nil {
|
||||
a.providerManager.ResetAvailability(fbProvider.Name())
|
||||
if fbProvider != a.provider {
|
||||
a.provider = fbProvider
|
||||
log.Printf("[agent] switched active provider to %q after fallback", fbProvider.Name())
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 用户中断:立即终止,不重试也不换 provider
|
||||
if errors.Is(llmErr, context.Canceled) {
|
||||
break
|
||||
}
|
||||
// 凭证错误:重试无意义,跳出重试循环进入 provider 标记/切换
|
||||
var pe *agentAPI.ProviderError
|
||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||
break
|
||||
}
|
||||
// 其余错误(含 5xx/429/网络):还有重试机会则继续,否则跳出
|
||||
}
|
||||
|
||||
if llmErr == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(llmErr, context.Canceled) {
|
||||
break
|
||||
}
|
||||
var pe *agentAPI.ProviderError
|
||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
||||
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
||||
} else {
|
||||
a.providerManager.MarkUnavailable(fbProvider.Name())
|
||||
}
|
||||
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
||||
}
|
||||
|
||||
return resp, llmErr
|
||||
}
|
||||
211
internal/agent/core/task_lifecycle_test.go
Normal file
211
internal/agent/core/task_lifecycle_test.go
Normal file
@ -0,0 +1,211 @@
|
||||
package core
|
||||
|
||||
// M3a 验收测试:任务生命周期(prepare → run → finish)与「每任务恰一次终态」。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §11.3(X2/X3/X4 的 M3a 形态):
|
||||
// 帧覆盖全生命周期后,提交(context.Append)与回执(emitResponse)只能在
|
||||
// finish 段发生一次——挂起不会重复提交。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func newLifecycleAgent(t *testing.T, sp agentAPI.Provider, bus *events.Bus, sh *StageHost) *Agent {
|
||||
t.Helper()
|
||||
return New(AgentConfig{
|
||||
ID: "lifecycle",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: sh,
|
||||
EventBus: bus,
|
||||
})
|
||||
}
|
||||
|
||||
func textEvent(source, content string) (*agentIO.InputEvent, chan *agentIO.OutputEvent) {
|
||||
ch := make(chan *agentIO.OutputEvent, 1)
|
||||
return &agentIO.InputEvent{
|
||||
RequestID: "req-1",
|
||||
Source: source,
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{"content": content},
|
||||
OutputChannel: source,
|
||||
ResponseCh: ch,
|
||||
}, ch
|
||||
}
|
||||
|
||||
// X3(M3a 形态):正常任务在 finish 段**恰好**提交一次并回执一次。
|
||||
func TestLifecycle_NormalCommitsOnceAndReplies(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
var outputs, rawInputs int
|
||||
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||
bus.Subscribe(events.EventRawInput, func(*events.Event) { rawInputs++ })
|
||||
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "答复"}}}
|
||||
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||
|
||||
evt, respCh := textEvent("cli", "你好")
|
||||
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||
t.Fatalf("runInputTask=%v,期望 outcomeDone", out)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-respCh:
|
||||
if got, _ := r.Payload["content"].(string); got != "答复" {
|
||||
t.Fatalf("回执内容=%q,期望 答复", got)
|
||||
}
|
||||
if !r.Done {
|
||||
t.Fatal("回执必须带 Done=true")
|
||||
}
|
||||
default:
|
||||
t.Fatal("同步回执缺失:finish 段必须写 ResponseCh")
|
||||
}
|
||||
|
||||
if outputs != 1 {
|
||||
t.Fatalf("agent_output 事件=%d,期望恰好 1(每任务一次终态)", outputs)
|
||||
}
|
||||
if rawInputs != 1 {
|
||||
t.Fatalf("raw_input 事件=%d,期望 1", rawInputs)
|
||||
}
|
||||
if a.context.Len() != 2 {
|
||||
t.Fatalf("上下文事件=%d,期望 2(输入事件 + 本轮事件)", a.context.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// X2:被去重的输入以 skipped 终态结束——不提交、不回执、不发输出事件。
|
||||
func TestLifecycle_DuplicateSkippedHasTerminal(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
outputs := 0
|
||||
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||
{Content: "第一次"}, {Content: "第二次"},
|
||||
}}
|
||||
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||
|
||||
e1, _ := textEvent("webui", "同样的消息")
|
||||
if _, out := a.runInputTask(e1); out != outcomeDone {
|
||||
t.Fatalf("首次输入=%v,期望 outcomeDone", out)
|
||||
}
|
||||
after1, outputs1 := a.context.Len(), outputs
|
||||
|
||||
e2, ch2 := textEvent("webui", "同样的消息")
|
||||
if _, out := a.runInputTask(e2); out != outcomeDone {
|
||||
t.Fatalf("去重输入应正常返回(不挂起),实际 %v", out)
|
||||
}
|
||||
if a.context.Len() != after1 {
|
||||
t.Fatalf("去重命中不得提交上下文:%d → %d", after1, a.context.Len())
|
||||
}
|
||||
if len(ch2) != 1 {
|
||||
t.Fatal("去重命中必须回一个 skipped 终态,否则同步调用方永久挂起")
|
||||
}
|
||||
r := <-ch2
|
||||
if skipped, _ := r.Payload["skipped"].(bool); !skipped {
|
||||
t.Fatalf("去重回执必须带 skipped=true,实际 %+v", r.Payload)
|
||||
}
|
||||
if reason, _ := r.Payload["reason"].(string); reason != "duplicate" {
|
||||
t.Fatalf("reason=%q,期望 duplicate", reason)
|
||||
}
|
||||
if outputs != outputs1 {
|
||||
t.Fatalf("去重命中不得发输出事件:%d → %d", outputs1, outputs)
|
||||
}
|
||||
}
|
||||
|
||||
// 被 on_input 阶段短路:回执阶段给的响应,且不提交上下文(与原实现一致)。
|
||||
func TestLifecycle_OnInputShortCircuit(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
reply := "被插件短路"
|
||||
sh.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
ctx.Response = &reply
|
||||
return nil
|
||||
})
|
||||
|
||||
sp := &scriptProvider{} // 不应被调用到
|
||||
a := newLifecycleAgent(t, sp, events.NewBus(), sh)
|
||||
|
||||
evt, respCh := textEvent("cli", "任意")
|
||||
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||
t.Fatalf("短路任务=%v,期望 outcomeDone", out)
|
||||
}
|
||||
select {
|
||||
case r := <-respCh:
|
||||
if got, _ := r.Payload["content"].(string); got != reply {
|
||||
t.Fatalf("短路响应=%q,期望 %q", got, reply)
|
||||
}
|
||||
default:
|
||||
t.Fatal("短路路径必须回执")
|
||||
}
|
||||
if a.context.Len() != 0 {
|
||||
t.Fatalf("短路路径不得提交上下文,实际 %d 条", a.context.Len())
|
||||
}
|
||||
if len(sp.reqs) != 0 {
|
||||
t.Fatal("短路路径不得调用 LLM")
|
||||
}
|
||||
}
|
||||
|
||||
// 错误路径:以 error 终态结束,回执错误文本,且提交的是**错误事件**(无 turn 事件)。
|
||||
func TestLifecycle_ErrorPathTerminal(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
outputs := 0
|
||||
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||
|
||||
sp := &scriptProvider{err: &agentAPI.ProviderError{StatusCode: 401, Message: "bad key"}}
|
||||
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||
|
||||
evt, respCh := textEvent("cli", "会失败")
|
||||
if _, out := a.runInputTask(evt); out != outcomeFailed {
|
||||
t.Fatalf("runInputTask=%v,期望 outcomeFailed", out)
|
||||
}
|
||||
select {
|
||||
case r := <-respCh:
|
||||
got, _ := r.Payload["content"].(string)
|
||||
if !strings.HasPrefix(got, "处理错误:") {
|
||||
t.Fatalf("错误回执=%q,期望以 处理错误: 开头", got)
|
||||
}
|
||||
default:
|
||||
t.Fatal("错误路径必须回执(否则同步调用方永久挂起)")
|
||||
}
|
||||
if outputs != 1 {
|
||||
t.Fatalf("错误路径的 agent_output 事件=%d,期望 1", outputs)
|
||||
}
|
||||
// 输入事件 + 错误事件 = 2;不得出现带 ToolsUsed 的 turn 事件。
|
||||
if a.context.Len() != 2 {
|
||||
t.Fatalf("错误路径上下文事件=%d,期望 2", a.context.Len())
|
||||
}
|
||||
recent := a.context.Recent(10)
|
||||
last := recent[len(recent)-1]
|
||||
if last.Response == "" {
|
||||
t.Fatal("错误事件必须带 Response")
|
||||
}
|
||||
}
|
||||
|
||||
// _consolidation_ 走记忆整理专用路径:不回执、不提交上下文。
|
||||
func TestLifecycle_ConsolidationRouted(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
outputs := 0
|
||||
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
|
||||
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "整理完毕"}}}
|
||||
a := newLifecycleAgent(t, sp, bus, NewStageHost())
|
||||
|
||||
evt, respCh := textEvent("system", "整理任务")
|
||||
evt.OutputChannel = channelConsolidation
|
||||
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||
t.Fatalf("consolidation=%v,期望 outcomeDone", out)
|
||||
}
|
||||
if len(respCh) != 0 {
|
||||
t.Fatal("consolidation 路径不得回执")
|
||||
}
|
||||
if outputs != 0 {
|
||||
t.Fatalf("consolidation 路径不得发输出事件,实际 %d", outputs)
|
||||
}
|
||||
if a.context.Len() != 0 {
|
||||
t.Fatalf("consolidation 路径不得写用户上下文,实际 %d", a.context.Len())
|
||||
}
|
||||
}
|
||||
132
internal/agent/core/task_terminal_test.go
Normal file
132
internal/agent/core/task_terminal_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package core
|
||||
|
||||
// M6 验收测试:任务级回执与断链点统一为终态事件。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §7(不变量 I5)、§11.3(X1–X4)。
|
||||
//
|
||||
// 问题背景:回执原先由全局 emitResponse 写(无任务归属),且 processInput 有多条
|
||||
// 「提前 return 而不 emit」的路径(解析失败、去重、consolidation)——同步调用方
|
||||
// 若不自带超时(cli、clawhubadapter)就会永久挂起。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// X1:回执按任务归属,中断的回执绝不投给被挂起的等待者。
|
||||
func TestTerminal_TaskScopedReplyNotMisrouted(t *testing.T) {
|
||||
sp := newPreemptProvider("intr-done", "low-done")
|
||||
a := newPreemptAgent(t, sp)
|
||||
|
||||
lowEvt, lowCh := textEvent("qq", "低优先级任务")
|
||||
lowTask := newInputTask(lowEvt)
|
||||
if !a.sched.enqueue(lowTask) {
|
||||
t.Fatal("入队失败")
|
||||
}
|
||||
lt, _, _ := a.sched.nextRef()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { a.executeNewTask(lt); close(done) }()
|
||||
select {
|
||||
case <-sp.entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("provider 未被调用")
|
||||
}
|
||||
|
||||
intrEvt, intrCh := textEvent("cli", "紧急打断")
|
||||
intrEvt.Payload["interrupt"] = true
|
||||
if !a.sched.requestKernelPreempt(intrEvt) {
|
||||
t.Fatal("内核 L4 应抢占排队任务")
|
||||
}
|
||||
a.cancelCurrentLLM()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("未挂起")
|
||||
}
|
||||
|
||||
// 执行中断任务 → 只应写它自己的回执通道。
|
||||
it, _, k := a.sched.nextRef()
|
||||
if k != nextImmediate {
|
||||
t.Fatalf("应取到立即运行的中断,kind=%v", k)
|
||||
}
|
||||
a.executeNewTask(it)
|
||||
if len(intrCh) != 1 {
|
||||
t.Fatalf("中断任务应回执到自己的通道,实际 %d", len(intrCh))
|
||||
}
|
||||
if len(lowCh) != 0 {
|
||||
t.Fatal("中断的回执绝不能被投给被挂起的等待者")
|
||||
}
|
||||
|
||||
// 恢复并结束后,原任务才拿到自己的回执。
|
||||
rt, rf, k2 := a.sched.nextRef()
|
||||
if k2 != nextSuspended {
|
||||
t.Fatalf("应恢复被抢占任务,kind=%v", k2)
|
||||
}
|
||||
a.resumeTask(rt, rf)
|
||||
if len(lowCh) != 1 {
|
||||
t.Fatalf("恢复任务结束后应恰好回执一次,实际 %d", len(lowCh))
|
||||
}
|
||||
if got, _ := (<-lowCh).Payload["content"].(string); got != "low-done" {
|
||||
t.Fatalf("原任务回执内容=%q,期望 low-done", got)
|
||||
}
|
||||
}
|
||||
|
||||
// X2:空输入(解析失败)也必须有终态回执。
|
||||
func TestTerminal_EmptyInputGetsSkippedReply(t *testing.T) {
|
||||
a := newLifecycleAgent(t, &scriptProvider{}, nil, NewStageHost())
|
||||
ch := make(chan *agentIO.OutputEvent, 1)
|
||||
evt := &agentIO.InputEvent{
|
||||
RequestID: "r-empty",
|
||||
Source: "cli",
|
||||
Type: "text",
|
||||
Payload: map[string]interface{}{}, // 无 content,无媒体块
|
||||
OutputChannel: "cli",
|
||||
ResponseCh: ch,
|
||||
}
|
||||
if _, out := a.runInputTask(evt); out != outcomeDone {
|
||||
t.Fatalf("空输入应正常返回,实际 %v", out)
|
||||
}
|
||||
if len(ch) != 1 {
|
||||
t.Fatal("空输入必须回 skipped 终态")
|
||||
}
|
||||
if reason, _ := (<-ch).Payload["reason"].(string); reason != "empty_input" {
|
||||
t.Fatalf("reason=%q,期望 empty_input", reason)
|
||||
}
|
||||
if a.context.Len() != 0 {
|
||||
t.Fatal("空输入不得写入上下文")
|
||||
}
|
||||
}
|
||||
|
||||
// X4:无超时的同步调用方(cli / clawhubadapter)在断链路径上不再永久挂起。
|
||||
//
|
||||
// 这是回归判据:修复前 `InjectTextSync` 遇到去重命中会永久阻塞。
|
||||
func TestTerminal_NoTimeoutSyncCallerDoesNotHang(t *testing.T) {
|
||||
a := newLifecycleAgent(t, &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||
{Content: "第一次"}, {Content: "第二次"},
|
||||
}}, nil, NewStageHost())
|
||||
|
||||
// 第一次成功
|
||||
e1, ch1 := textEvent("cli", "重复内容")
|
||||
if _, out := a.runInputTask(e1); out != outcomeDone {
|
||||
t.Fatalf("首次=%v", out)
|
||||
}
|
||||
if len(ch1) != 1 {
|
||||
t.Fatal("首次应有回执")
|
||||
}
|
||||
|
||||
// 第二次(去重命中):模拟同步调用方阻塞等待——必须在 1s 内拿到终态。
|
||||
e2, ch2 := textEvent("cli", "重复内容")
|
||||
go a.runInputTask(e2)
|
||||
select {
|
||||
case r := <-ch2:
|
||||
if skipped, _ := r.Payload["skipped"].(bool); !skipped {
|
||||
t.Fatalf("应为 skipped 终态,实际 %+v", r.Payload)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("去重命中让同步调用方永久挂起(X4 回归)")
|
||||
}
|
||||
}
|
||||
238
internal/agent/core/task_test.go
Normal file
238
internal/agent/core/task_test.go
Normal file
@ -0,0 +1,238 @@
|
||||
package core
|
||||
|
||||
// M1 验收测试:状态机与 TaskFrame 的**行为等价性**。
|
||||
//
|
||||
// 设计依据 docs/zh/input-scheduler-design.md §11(R3 / X3 的 M1 形态):
|
||||
// M1 不引入抢占,因此 R3 退化为「经状态机跑出的结果与脚本预期一致」;
|
||||
// X3 在 M1 退化为「驱动循环必然以一次终态返回结束(不空转、不超步数)」。
|
||||
//
|
||||
// 抢占/挂起/恢复/优先级在 M3 起才有测试。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// scriptProvider 按脚本依次返回 CompletionResponse。
|
||||
//
|
||||
// ChatStream 故意返回错误:驱动 chatStreamWithFallback 走非流式回退,
|
||||
// 这样脚本就是「第 N 次调用返回第 N 个响应」,不依赖流式分片语义。
|
||||
type scriptProvider struct {
|
||||
script []*agentAPI.CompletionResponse
|
||||
idx int
|
||||
reqs []*agentAPI.CompletionRequest
|
||||
// err 非空时 Chat 直接返回它(用于错误路径测试)。
|
||||
// 配合 ProviderError(401) 可跳过 2s 瞬时重试,让测试保持快速。
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *scriptProvider) Name() string { return "script" }
|
||||
func (s *scriptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||||
s.reqs = append(s.reqs, req)
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.idx >= len(s.script) {
|
||||
return &agentAPI.CompletionResponse{Content: ""}, nil
|
||||
}
|
||||
r := s.script[s.idx]
|
||||
s.idx++
|
||||
return r, nil
|
||||
}
|
||||
func (s *scriptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||||
return nil, errors.New("script provider: streaming disabled")
|
||||
}
|
||||
func (s *scriptProvider) MaxContextTokens() int { return 8192 }
|
||||
|
||||
func newTaskTestAgent(t *testing.T, sp agentAPI.Provider, sh *StageHost) *Agent {
|
||||
t.Helper()
|
||||
return New(AgentConfig{
|
||||
ID: "task-test",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
StageHost: sh,
|
||||
IO: agentIO.NewIOManager(),
|
||||
})
|
||||
}
|
||||
|
||||
func tc(id, name string) agentAPI.ToolCall {
|
||||
return agentAPI.ToolCall{ID: id, Name: name, Arguments: map[string]interface{}{"q": id}}
|
||||
}
|
||||
|
||||
// R3(M1 形态):一次工具轮 + 一次收尾轮,结果与工具调用计数必须正确。
|
||||
func TestTaskFrame_R3_ToolRoundTrip(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
var got []string
|
||||
sh.RegisterTool("t_echo", sdk.ToolDef{Name: "t_echo", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
got = append(got, args["q"].(string))
|
||||
return "OUT", nil
|
||||
})
|
||||
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||
{Content: "让我调用工具", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_echo")}},
|
||||
{Content: "最终答复"},
|
||||
}}
|
||||
a := newTaskTestAgent(t, sp, sh)
|
||||
|
||||
stageCtx := a.stageCtxFromInput("你好", "", "")
|
||||
resp, toolsUsed, toolResults, err := a.process("你好", stageCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("process 返回错误: %v", err)
|
||||
}
|
||||
if resp != "最终答复" {
|
||||
t.Fatalf("响应=%q,期望 %q", resp, "最终答复")
|
||||
}
|
||||
if len(toolsUsed) != 1 || toolsUsed[0] != "t_echo" {
|
||||
t.Fatalf("toolsUsed=%v,期望恰好一次 t_echo", toolsUsed)
|
||||
}
|
||||
if len(toolResults) != 1 || toolResults[0].Name != "t_echo" || toolResults[0].Output != "OUT" {
|
||||
t.Fatalf("toolResults=%+v,期望一条 t_echo/OUT", toolResults)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "c1" {
|
||||
t.Fatalf("工具实参=%v,期望恰好执行一次且参数来自脚本", got)
|
||||
}
|
||||
if len(sp.reqs) != 2 {
|
||||
t.Fatalf("LLM 调用次数=%d,期望 2(工具轮 + 收尾轮)", len(sp.reqs))
|
||||
}
|
||||
|
||||
// 第二轮请求必须携带 assistant(tool_call) + tool 结果两条消息。
|
||||
msgs := sp.reqs[1].Messages
|
||||
var hasAssistantCall, hasToolResult bool
|
||||
for _, m := range msgs {
|
||||
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "c1" {
|
||||
hasAssistantCall = true
|
||||
}
|
||||
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "OUT" {
|
||||
hasToolResult = true
|
||||
}
|
||||
}
|
||||
if !hasAssistantCall || !hasToolResult {
|
||||
t.Fatalf("第二轮请求缺少工具调用配对:assistant=%v tool=%v", hasAssistantCall, hasToolResult)
|
||||
}
|
||||
}
|
||||
|
||||
// X3(M1 形态):多轮脚本必须在有限步内以一次终态返回结束。
|
||||
func TestTaskFrame_X3_TerminatesWithinBudget(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
sh.RegisterTool("t_noop", sdk.ToolDef{Name: "t_noop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
|
||||
// 3 个工具轮 + 收尾轮:状态机会在 StepLLM/StepToolBegin/.../StepTurnEnd 间往返 4 次。
|
||||
var script []*agentAPI.CompletionResponse
|
||||
for i := 0; i < 3; i++ {
|
||||
script = append(script, &agentAPI.CompletionResponse{
|
||||
Content: "round",
|
||||
ToolCalls: []agentAPI.ToolCall{tc("c"+string(rune('a'+i)), "t_noop")},
|
||||
})
|
||||
}
|
||||
script = append(script, &agentAPI.CompletionResponse{Content: "done"})
|
||||
|
||||
sp := &scriptProvider{script: script}
|
||||
a := newTaskTestAgent(t, sp, sh)
|
||||
|
||||
resp, toolsUsed, toolResults, err := a.process("跑三轮", a.stageCtxFromInput("跑三轮", "", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("process 返回错误: %v", err)
|
||||
}
|
||||
if resp != "done" {
|
||||
t.Fatalf("响应=%q,期望 done", resp)
|
||||
}
|
||||
if len(toolsUsed) != 3 || len(toolResults) != 3 {
|
||||
t.Fatalf("toolsUsed=%d toolResults=%d,期望各 3", len(toolsUsed), len(toolResults))
|
||||
}
|
||||
// 步数护栏未触发(触发了会是 "step budget exhausted" 错误)。
|
||||
if len(sp.reqs) != 4 {
|
||||
t.Fatalf("LLM 调用次数=%d,期望 4", len(sp.reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// 状态机对未知 step 必须失败退出而不是空转。
|
||||
func TestTaskFrame_UnknownStepFails(t *testing.T) {
|
||||
sp := &scriptProvider{}
|
||||
a := newTaskTestAgent(t, sp, NewStageHost())
|
||||
f := a.newTaskFrame("x", a.stageCtxFromInput("x", "", ""))
|
||||
f.Step = Step(999)
|
||||
if out := a.step(f); out != outcomeFailed {
|
||||
t.Fatalf("未知 step 应返回 outcomeFailed,实际 %v", out)
|
||||
}
|
||||
if f.Err == nil {
|
||||
t.Fatal("未知 step 必须带错误信息")
|
||||
}
|
||||
}
|
||||
|
||||
// D6:工具轮次硬上限——模型不停调用工具时,必须在有限步内收尾。
|
||||
//
|
||||
// 这是审查里定位的 P0(core.agent.max_tool_turns 只定义、从没被读过),
|
||||
// 也是调度器的前提:任务必须可终止。
|
||||
func TestMaxToolTurns_CapsRunawayLoop(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "again", nil
|
||||
})
|
||||
|
||||
// 脚本远长于上限:provider 每轮都给下一批工具调用,模拟“永不停止”。
|
||||
script := make([]*agentAPI.CompletionResponse, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
script = append(script, &agentAPI.CompletionResponse{
|
||||
Content: "继续",
|
||||
ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")},
|
||||
})
|
||||
}
|
||||
sp := &scriptProvider{script: script}
|
||||
a := New(AgentConfig{
|
||||
ID: "cap",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: sh,
|
||||
MaxToolTurns: 3,
|
||||
})
|
||||
|
||||
resp, toolsUsed, toolResults, err := a.process("循环", a.stageCtxFromInput("循环", "", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("process 返回错误: %v", err)
|
||||
}
|
||||
if len(toolsUsed) != 3 || len(toolResults) != 3 {
|
||||
t.Fatalf("工具批=%d/%d,期望恰好 3(到上限即止,不多跑第 4 轮)", len(toolsUsed), len(toolResults))
|
||||
}
|
||||
if len(sp.reqs) != 3 {
|
||||
t.Fatalf("LLM 调用=%d,期望 3(上限后不再发起新请求)", len(sp.reqs))
|
||||
}
|
||||
if resp != "继续" {
|
||||
t.Fatalf("响应=%q,期望返回最近一次 LLM 文本", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// 上限为 0 表示不限(显式退出机制)。
|
||||
func TestMaxToolTurns_ZeroMeansUnlimited(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "again", nil
|
||||
})
|
||||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||||
{Content: "a", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")}},
|
||||
{Content: "b", ToolCalls: []agentAPI.ToolCall{tc("c2", "t_loop")}},
|
||||
{Content: "c"},
|
||||
}}
|
||||
a := New(AgentConfig{
|
||||
ID: "nocap",
|
||||
Provider: sp,
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: sh,
|
||||
MaxToolTurns: 0,
|
||||
})
|
||||
resp, toolsUsed, _, err := a.process("x", a.stageCtxFromInput("x", "", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("process 返回错误: %v", err)
|
||||
}
|
||||
if len(toolsUsed) != 2 || resp != "c" {
|
||||
t.Fatalf("不限时应跑完脚本:tools=%d resp=%q", len(toolsUsed), resp)
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
||||
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stack := debug.Stack()
|
||||
@ -31,7 +31,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
done <- a.executeToolCallInner(tc)
|
||||
done <- a.executeToolCallInner(tc, channel)
|
||||
}()
|
||||
|
||||
select {
|
||||
@ -43,8 +43,10 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
||||
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) string {
|
||||
switch {
|
||||
case tc.Name == "persona_set":
|
||||
return a.executePersonaTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "memory_"):
|
||||
return a.executeMemoryTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "social_"):
|
||||
@ -59,13 +61,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
||||
return a.executeOutputSendTool(tc)
|
||||
case tc.Name == "output_list_channels":
|
||||
return a.executeOutputListChannels()
|
||||
case tc.Name == "input_channels":
|
||||
return a.executeInputChannels(tc)
|
||||
case tc.Name == "resident_agents":
|
||||
return a.executeResidentAgents(tc)
|
||||
case tc.Name == "notify_parent":
|
||||
return a.executeNotifyParent(tc)
|
||||
case tc.Name == "inputch_note":
|
||||
return a.executeInputchNote(tc)
|
||||
case tc.Name == "plgreload":
|
||||
return a.executePluginReload()
|
||||
case tc.Name == "get_plugin_tools":
|
||||
pluginName, _ := tc.Arguments["plugin_name"].(string)
|
||||
return a.executeGetPluginTools(pluginName)
|
||||
case tc.Name == "spawn_child":
|
||||
return a.executeSpawnChild(tc)
|
||||
return a.executeSpawnChild(tc, channel)
|
||||
case tc.Name == "child_result":
|
||||
return a.executeChildResultTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "llm_"):
|
||||
@ -86,6 +96,18 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 设备类工具的**授权闸**(最小授权的缺口在这里)。
|
||||
//
|
||||
// 设备指令类工具(device_ctl_cmdrun/screensee/computeruse/...)走的是工具面,
|
||||
// 而 AllowedOutputs 只作用于 output_send__<通道> —— 于是"授权"对指令类工具完全无效:
|
||||
// 驻留子只要拿到 device_ctl_cmdrun 就能指挥**任意**设备。
|
||||
// 这里按目标设备的通道名 device/<id> 查同一道闸:父授权了哪台设备,才允许指挥哪台。
|
||||
if _, isDeviceTool := a.io.DeviceOfTool(tc.Name); isDeviceTool {
|
||||
if id, _ := tc.Arguments["device_id"].(string); id != "" && !a.IsOutputAllowed("device/"+id) {
|
||||
return fmt.Sprintf("设备 [%s] 未授权给本 agent(可用设备见 output_list_channels 的 device/<id> 通道,或 devicedetect)", id)
|
||||
}
|
||||
}
|
||||
|
||||
if a.tracker != nil {
|
||||
a.tracker.PreAction(tc.Name)
|
||||
}
|
||||
@ -102,12 +124,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
|
||||
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
if a.memory == nil {
|
||||
g := a.graphMem()
|
||||
if g == nil {
|
||||
if tc.Name == "memory_document_query" {
|
||||
return a.executeDocTool(tc)
|
||||
}
|
||||
return "图记忆系统不可用"
|
||||
}
|
||||
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
|
||||
// 轻量内核(驻留子)只有图记忆共同面 ⇒ 这些操作明确不可用,不静默降级。
|
||||
requireFull := func() string {
|
||||
if a.memory == nil {
|
||||
return "本 agent 是轻量内核:只能读写图记忆,记忆整理(合并/删除/清理/编辑/统计)不可用"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch tc.Name {
|
||||
case "memory_recall":
|
||||
query, _ := tc.Arguments["query_intent"].(string)
|
||||
@ -123,7 +154,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
if len(keywords) == 1 {
|
||||
keywords = memory.ExtractKeywords(query)
|
||||
}
|
||||
result, err := a.memory.Recall(keywords, nil, int(depth), "")
|
||||
result, err := g.Recall(keywords, nil, int(depth), "")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("记忆检索失败: %v", err)
|
||||
}
|
||||
@ -161,6 +192,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
return strings.Join(parts, "\n")
|
||||
|
||||
case "memory_block_merge":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
entityA, _ := tc.Arguments["entity_a"].(string)
|
||||
entityB, _ := tc.Arguments["entity_b"].(string)
|
||||
rounds, _ := tc.Arguments["rounds"].(float64)
|
||||
@ -221,6 +255,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
|
||||
|
||||
case "memory_introspect":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
stats, err := a.memory.Introspect()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("查询失败: %v", err)
|
||||
@ -231,6 +268,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
return a.executeDocTool(tc)
|
||||
|
||||
case "memory_merge":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
source, _ := tc.Arguments["source"].(string)
|
||||
target, _ := tc.Arguments["target"].(string)
|
||||
if source == "" || target == "" {
|
||||
@ -243,6 +283,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
|
||||
|
||||
case "memory_delete_entity":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
name, _ := tc.Arguments["name"].(string)
|
||||
if name == "" {
|
||||
return "name 不能为空"
|
||||
@ -253,6 +296,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
|
||||
|
||||
case "memory_purge":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
criteria := make(map[string]string)
|
||||
if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" {
|
||||
criteria["subject_contains"] = v
|
||||
@ -287,6 +333,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
return strings.Join(parts, ",")
|
||||
|
||||
case "memory_edit":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
oldSubject, _ := tc.Arguments["old_subject"].(string)
|
||||
oldRelation, _ := tc.Arguments["old_relation"].(string)
|
||||
oldObject, _ := tc.Arguments["old_object"].(string)
|
||||
|
||||
@ -43,6 +43,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
|
||||
}
|
||||
|
||||
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
|
||||
if a.taskPrompt != "" {
|
||||
prompt += "\n\n【任务】" + a.taskPrompt
|
||||
}
|
||||
|
||||
if a.personality != nil {
|
||||
if pp := a.personality.InjectPrompt(); pp != "" {
|
||||
prompt += "\n\n" + pp
|
||||
@ -68,8 +73,8 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
|
||||
|
||||
prompt += "\n\n【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n"
|
||||
prompt += "- 当前输入来自哪个通道,就优先用哪个通道回复;不要串到其他通道(除非用户明确要求)。\n"
|
||||
prompt += "- 当前输入来源通道(即对话发生的通道)是:" + a.currentOutputChannel + "。对应输出门工具是 output_send__{该通道名}。\n"
|
||||
prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n"
|
||||
prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n"
|
||||
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
|
||||
prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n"
|
||||
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
|
||||
@ -89,6 +94,16 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 首启人格门禁(跨通道唯一闸口):人格未确认时,要求模型主动询问用户。
|
||||
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
|
||||
// 模型调用 persona_set(或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
|
||||
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
|
||||
prompt += "\n\n【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:" +
|
||||
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:" +
|
||||
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;" +
|
||||
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。"
|
||||
}
|
||||
|
||||
prompt += a.buildToolCatalog()
|
||||
|
||||
return prompt
|
||||
@ -197,6 +212,24 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
if a.personaStore != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "persona_set",
|
||||
"description": "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容(mode=custom 时必填)"},
|
||||
},
|
||||
"required": []string{"mode"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if a.memory != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
@ -552,6 +585,11 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
|
||||
continue
|
||||
}
|
||||
// 输出通道授权(设计 §4.4/R2):默认完整授权;父可用白名单收窄子的输出能力。
|
||||
// 未授权就不生成 output_send__X —— 模型看不到它,自然不会调。
|
||||
if !a.IsOutputAllowed(ch.Name) {
|
||||
continue
|
||||
}
|
||||
capStr := a.io.GetChannelCapabilities(ch.Name).String()
|
||||
desc := ch.Description
|
||||
if desc == "" {
|
||||
@ -609,6 +647,90 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
},
|
||||
})
|
||||
|
||||
// 父侧:驻留子控制面(单工具多动作,见设计 §7)。
|
||||
if a.parentID == "" {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "resident_agents",
|
||||
"description": "管理驻留子 agent(长期派驻的下属):list 列出 / create 创建(划入 inputch + " +
|
||||
"授权输出通道 + 注入任务提示词)/ send 发送消息(对子而言是 L4 中断,取消其当前状态并插入新消息)" +
|
||||
"/ inspect 查看其 inputch 处理表(不打断它)/ compress 压缩其上下文(保留语义,子继续存在)" +
|
||||
"/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"list", "create", "send", "inspect", "compress", "reclaim", "destroy"},
|
||||
},
|
||||
"id": map[string]interface{}{"type": "string", "description": "驻留子 id"},
|
||||
"task_prompt": map[string]interface{}{"type": "string", "description": "create:在固定提示词之上注入的任务提示词"},
|
||||
"input_chs": map[string]interface{}{"type": "string", "description": "create:划入的 inputch(逗号分隔)"},
|
||||
"allowed_outputs": map[string]interface{}{"type": "string", "description": "create:授权的输出通道(逗号分隔;留空=完整授权)"},
|
||||
"capacity": map[string]interface{}{"type": "number", "description": "create:划入 inputch 的队列容量"},
|
||||
"temp_path": map[string]interface{}{"type": "string", "description": "create:temp 图记忆路径(留空则用 data_dir/residents/<id>/graph.db)"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 子侧(驻留子):主动汇报(L3)与主动写处理表。
|
||||
if a.parentID != "" {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "notify_parent",
|
||||
"description": "向主 agent 汇报(以 L3 中断投给它)。用于主动报告进展/结论,而不是等它来问。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "汇报内容"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "inputch_note",
|
||||
"description": "为**本轮** inputch 主动写入处理信息(主 agent 会查这张表判断你的进度)。" +
|
||||
"写了就不会再被系统自动记录;不写则本轮结束时系统自动写。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "本轮处理信息摘要"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "input_channels",
|
||||
"description": "查看 inputch(最基本的输入路由单位):哪些已注册、谁注册的、" +
|
||||
"各自划给了哪个 agent、容量与记忆策略。单工具多视图。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"view": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "all=全部已注册(默认)| mine=划给本 agent 的 | " +
|
||||
"unassigned=尚未划出的 | by_agent=按归属分组的划分总览 | detail=单个详情",
|
||||
"enum": []string{"all", "mine", "unassigned", "by_agent", "detail"},
|
||||
},
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "view=detail 时必填:inputch 名",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if a.pendingMedia != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
|
||||
@ -96,13 +96,13 @@ type OutputEvent struct {
|
||||
}
|
||||
|
||||
type IOManager struct {
|
||||
mu sync.RWMutex
|
||||
devices map[string]Device
|
||||
inputCh chan *InputEvent
|
||||
interruptCh chan *InputEvent
|
||||
outputCh chan *OutputEvent
|
||||
nextReqID int64
|
||||
inputChannels map[string]ChannelDef
|
||||
mu sync.RWMutex
|
||||
devices map[string]Device
|
||||
inputCh chan *InputEvent
|
||||
interruptCh chan *InputEvent
|
||||
outputCh chan *OutputEvent
|
||||
nextReqID int64
|
||||
channelReg *ChannelRegistry
|
||||
|
||||
// toolBlocks:插件工具注入多模态内容块,process.go 在下一条 tool message 时消费。
|
||||
// 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。
|
||||
@ -112,11 +112,11 @@ type IOManager struct {
|
||||
|
||||
func NewIOManager() *IOManager {
|
||||
return &IOManager{
|
||||
devices: make(map[string]Device),
|
||||
inputCh: make(chan *InputEvent, 256),
|
||||
interruptCh: make(chan *InputEvent, 64),
|
||||
outputCh: make(chan *OutputEvent, 256),
|
||||
inputChannels: make(map[string]ChannelDef),
|
||||
devices: make(map[string]Device),
|
||||
inputCh: make(chan *InputEvent, 256),
|
||||
interruptCh: make(chan *InputEvent, 64),
|
||||
outputCh: make(chan *OutputEvent, 256),
|
||||
channelReg: NewChannelRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -266,6 +266,10 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
|
||||
if opts.CleanerName != "" {
|
||||
payload["cleaner_name"] = opts.CleanerName
|
||||
}
|
||||
// priority 只对中断注入有意义;排队路径会忽略它(内核侧只读不写)。
|
||||
if opts.Priority != "" {
|
||||
payload["priority"] = opts.Priority
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
||||
@ -440,26 +444,57 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
|
||||
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
|
||||
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
|
||||
|
||||
// RegisterInputChannel 注册输入通道的记忆行为
|
||||
// RegisterInputChannel 注册一个 inputch(不带插件归属,兼容旧调用)。
|
||||
//
|
||||
// inputch 是**最基本的输入路由单位**;一个插件可以注册多个。
|
||||
// 新代码请用 RegisterInputChannelFrom 以便登记归属插件(可追溯)。
|
||||
func (m *IOManager) RegisterInputChannel(name string, def ChannelDef) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.inputChannels[name] = def
|
||||
_ = m.RegisterInputChannelFrom("", name, def)
|
||||
}
|
||||
|
||||
// UnregisterInputChannel 注销输入通道
|
||||
// RegisterInputChannelFrom 注册一个 inputch 并登记归属插件。
|
||||
func (m *IOManager) RegisterInputChannelFrom(plugin, name string, def ChannelDef) error {
|
||||
return m.channelReg.Register(InputChannel{Name: name, Plugin: plugin, Def: def})
|
||||
}
|
||||
|
||||
// UnregisterInputChannel 注销一个 inputch。
|
||||
func (m *IOManager) UnregisterInputChannel(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.inputChannels, name)
|
||||
m.channelReg.Unregister(name)
|
||||
}
|
||||
|
||||
// GetInputChannelDef 查询输入通道的记忆行为定义
|
||||
// AssignInputChannel 把一个 inputch 划给某个 agent(见 ChannelRegistry.Assign)。
|
||||
func (m *IOManager) AssignInputChannel(name, agentID string, capacity int) error {
|
||||
return m.channelReg.Assign(name, agentID, capacity)
|
||||
}
|
||||
|
||||
// SetChannelRegistry 注入一份**共享的**登记表(根 agent 与驻留子共用同一份)。
|
||||
func (m *IOManager) SetChannelRegistry(r *ChannelRegistry) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.channelReg = r
|
||||
}
|
||||
|
||||
// ChannelRegistry 返回底层登记表(只读用途;可直接读 Views)。
|
||||
func (m *IOManager) ChannelRegistry() *ChannelRegistry { return m.channelReg }
|
||||
|
||||
// InputChannels 返回全部已注册 inputch(按名字排序)。
|
||||
func (m *IOManager) InputChannels() []InputChannel { return m.channelReg.List() }
|
||||
|
||||
// LookupInputChannel 查询单个 inputch 的完整登记记录。
|
||||
func (m *IOManager) LookupInputChannel(name string) (InputChannel, bool) {
|
||||
return m.channelReg.Lookup(name)
|
||||
}
|
||||
|
||||
// GetInputChannelDef 查询 inputch 的记忆行为定义。
|
||||
func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
def, ok := m.inputChannels[name]
|
||||
return def, ok
|
||||
ch, ok := m.channelReg.Lookup(name)
|
||||
if !ok {
|
||||
return ChannelDef{}, false
|
||||
}
|
||||
return ch.Def, true
|
||||
}
|
||||
|
||||
func (m *IOManager) GetAllTools() []ToolDef {
|
||||
@ -472,6 +507,23 @@ func (m *IOManager) GetAllTools() []ToolDef {
|
||||
return tools
|
||||
}
|
||||
|
||||
// DeviceOfTool 返回提供该工具的**设备/输出通道名**(设备类工具才有)。
|
||||
//
|
||||
// 用途:设备类工具(device_ctl_*/screensee/computeruse/...)需要按"目标设备"
|
||||
// 做授权判断,调用方得先知道这个工具属于哪个设备通道。
|
||||
func (m *IOManager) DeviceOfTool(name string) (string, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, dev := range m.devices {
|
||||
for _, t := range dev.Tools() {
|
||||
if t.Name == name {
|
||||
return dev.Name(), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||
m.mu.RLock()
|
||||
type nameDevice struct {
|
||||
|
||||
203
internal/agent/io/inputch.go
Normal file
203
internal/agent/io/inputch.go
Normal file
@ -0,0 +1,203 @@
|
||||
package io
|
||||
|
||||
// inputch 是**最基本的输入路由单位**(设计见 docs/zh/resident-subagent-design.md §4)。
|
||||
//
|
||||
// 一个插件可以注册多个 inputch;每个 inputch 是彼此独立的路由单位:
|
||||
// 可以被划给不同的 agent、可以分别限额。中断输入与排队输入**两类都从 inputch 进出**,
|
||||
// 而"中断 vs 排队"是每条输入自己的类别 —— 不是 inputch 的属性。
|
||||
//
|
||||
// 本文件只放**登记表**(谁是注册者、划给了谁、容量多少、记忆策略是什么)。
|
||||
// 路由本身发生在进内核之前:投递方决定"这条输入投给哪个 inputch"。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInputChannelUnknown 表示引用了未注册的 inputch。
|
||||
ErrInputChannelUnknown = errors.New("inputch 未注册")
|
||||
// ErrInputChannelNameEmpty 表示 inputch 名为空。
|
||||
ErrInputChannelNameEmpty = errors.New("inputch 名不能为空")
|
||||
)
|
||||
|
||||
// InputChannel 是一个 inputch 的完整登记记录。
|
||||
type InputChannel struct {
|
||||
// Name 是路由单位 id(全局唯一,如 "qq"、"webui"、"qq/device-2")。
|
||||
Name string `json:"name"`
|
||||
// Plugin 是注册它的插件名("插件可注册多个 inputch",归属可追溯)。
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
// Owner 是**被划给的 agent id**("" = 未分配,归根 agent/内核默认)。
|
||||
Owner string `json:"owner,omitempty"`
|
||||
// Capacity 是该 inputch 的队列容量(0 = 用内核默认值)。
|
||||
Capacity int `json:"capacity,omitempty"`
|
||||
// Output 是该 inputch 的默认回程输出通道("" = 由来源/调用方决定)。
|
||||
//
|
||||
// 注意:这不是"内核路由"—— 输出仍然是 agent 的主动调用;这里只是登记
|
||||
// "这个 inputch 的回复默认该往哪个 outputch 走"的映射依据。
|
||||
Output string `json:"output,omitempty"`
|
||||
// Def 是记忆/上下文策略(沿用 ChannelDef:NoMemory / Cleaner / ContextPolicy)。
|
||||
// Cleaner 是函数,故本字段不可序列化(json:"-")。
|
||||
Def ChannelDef `json:"-"`
|
||||
}
|
||||
|
||||
// ChannelRegistry 是 inputch 的登记表。
|
||||
//
|
||||
// 它被设计成**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
|
||||
// 这样"划入/授权"才有意义;每个 IOManager 默认自带一份(向后兼容)。
|
||||
type ChannelRegistry struct {
|
||||
mu sync.RWMutex
|
||||
channels map[string]InputChannel
|
||||
// outputTargets 把**输出通道**解析成"目标 agent 的哪个 inputch"。
|
||||
// 这是"输出可寻址到具体 agent"的依据(子→父、父→指定子)。
|
||||
outputTargets map[string]OutputTarget
|
||||
}
|
||||
|
||||
// OutputTarget 是一个输出通道的投递目标。
|
||||
type OutputTarget struct {
|
||||
// AgentID 是目标 agent("" = 本 agent / 由传输层通道 device 自行处理)。
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
// InputCh 是目标 agent 上接收它的 inputch("" = 与输出通道同名)。
|
||||
InputCh string `json:"inputch,omitempty"`
|
||||
}
|
||||
|
||||
// NewChannelRegistry 构造一个空的 inputch 登记表。
|
||||
func NewChannelRegistry() *ChannelRegistry {
|
||||
return &ChannelRegistry{
|
||||
channels: make(map[string]InputChannel),
|
||||
outputTargets: make(map[string]OutputTarget),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 登记/更新一个 inputch。
|
||||
//
|
||||
// 重复登记(插件重载)时**保留已有的 Owner/Capacity/Output**,只更新
|
||||
// Plugin 与 Def —— 否则一次插件重载就会把父 agent 做的划分抹掉。
|
||||
func (r *ChannelRegistry) Register(ch InputChannel) error {
|
||||
if ch.Name == "" {
|
||||
return ErrInputChannelNameEmpty
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.channels == nil {
|
||||
r.channels = make(map[string]InputChannel)
|
||||
}
|
||||
if old, ok := r.channels[ch.Name]; ok {
|
||||
if ch.Owner == "" {
|
||||
ch.Owner = old.Owner
|
||||
}
|
||||
if ch.Capacity == 0 {
|
||||
ch.Capacity = old.Capacity
|
||||
}
|
||||
if ch.Output == "" {
|
||||
ch.Output = old.Output
|
||||
}
|
||||
if ch.Plugin == "" {
|
||||
ch.Plugin = old.Plugin
|
||||
}
|
||||
}
|
||||
r.channels[ch.Name] = ch
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister 注销一个 inputch。
|
||||
func (r *ChannelRegistry) Unregister(name string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.channels, name)
|
||||
}
|
||||
|
||||
// Lookup 查询一个 inputch。
|
||||
func (r *ChannelRegistry) Lookup(name string) (InputChannel, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
ch, ok := r.channels[name]
|
||||
return ch, ok
|
||||
}
|
||||
|
||||
// Assign 把一个 inputch **划给**某个 agent(可同时给定容量)。
|
||||
//
|
||||
// 语义(默认取值,见设计文档 R6):**读写授权**,不转移所有权 ——
|
||||
// 登记表仍记录 Plugin(谁注册的)与 Owner(划给了谁)两件事。
|
||||
func (r *ChannelRegistry) Assign(name, agentID string, capacity int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
ch, ok := r.channels[name]
|
||||
if !ok {
|
||||
return ErrInputChannelUnknown
|
||||
}
|
||||
ch.Owner = agentID
|
||||
if capacity > 0 {
|
||||
ch.Capacity = capacity
|
||||
}
|
||||
r.channels[name] = ch
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 返回全部已注册 inputch(按名字稳定排序)。
|
||||
func (r *ChannelRegistry) List() []InputChannel {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]InputChannel, 0, len(r.channels))
|
||||
for _, ch := range r.channels {
|
||||
out = append(out, ch)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// ListByOwner 返回划给某个 agent 的 inputch(owner == "" 时返回**未分配**的)。
|
||||
func (r *ChannelRegistry) ListByOwner(agentID string) []InputChannel {
|
||||
var out []InputChannel
|
||||
for _, ch := range r.List() {
|
||||
if ch.Owner == agentID {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BindOutputTarget 登记"输出通道 → 目标 agent 的 inputch"的解析。
|
||||
//
|
||||
// 例:父把子用的输出通道 "to-child-1" 绑到 (child-1, "sub/in"),
|
||||
// 于是子经该通道发出的消息会投进 child-1 的 sub/in。
|
||||
func (r *ChannelRegistry) BindOutputTarget(output, agentID, inputCh string) error {
|
||||
if output == "" {
|
||||
return errors.New("输出通道名不能为空")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.outputTargets == nil {
|
||||
r.outputTargets = make(map[string]OutputTarget)
|
||||
}
|
||||
r.outputTargets[output] = OutputTarget{AgentID: agentID, InputCh: inputCh}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveOutputTarget 解析一个输出通道的目标;未登记时 ok=false
|
||||
// (意味着由传输层通道自行处理,如 qq/webui 这类 device 通道)。
|
||||
func (r *ChannelRegistry) ResolveOutputTarget(output string) (OutputTarget, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
t, ok := r.outputTargets[output]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// ListOutputTargets 返回全部已登记的目标解析(按输出通道名排序)。
|
||||
func (r *ChannelRegistry) ListOutputTargets() map[string]OutputTarget {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make(map[string]OutputTarget, len(r.outputTargets))
|
||||
for k, v := range r.outputTargets {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Count 返回已注册 inputch 数量。
|
||||
func (r *ChannelRegistry) Count() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.channels)
|
||||
}
|
||||
134
internal/agent/io/inputch_test.go
Normal file
134
internal/agent/io/inputch_test.go
Normal file
@ -0,0 +1,134 @@
|
||||
package io
|
||||
|
||||
// inputch 登记表与划分(单工具多视图背后的数据面)。
|
||||
//
|
||||
// 设计依据 docs/zh/resident-subagent-design.md §4:
|
||||
// inputch 是**最基本的输入路由单位**,由插件注册,一个插件可注册多个。
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChannelRegistry_RegisterKeepsAttribution(t *testing.T) {
|
||||
r := NewChannelRegistry()
|
||||
|
||||
// 一个插件注册多个 inputch(最基本的输入路由单位)。
|
||||
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Register(InputChannel{Name: "qq/device-2", Plugin: "qq"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := r.Count(); n != 2 {
|
||||
t.Fatalf("注册数=%d,期望 2(同一插件的多个 inputch 彼此独立)", n)
|
||||
}
|
||||
|
||||
ch, ok := r.Lookup("qq/device-2")
|
||||
if !ok || ch.Plugin != "qq" {
|
||||
t.Fatalf("归属插件未记录:%+v ok=%v", ch, ok)
|
||||
}
|
||||
if _, ok := r.Lookup("nope"); ok {
|
||||
t.Fatal("未注册的 inputch 不应查得到")
|
||||
}
|
||||
if err := r.Register(InputChannel{Name: ""}); err != ErrInputChannelNameEmpty {
|
||||
t.Fatalf("空名应报错,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 插件重载(重复登记)**不得抹掉划分**:Owner/Capacity/Output 必须保留。
|
||||
func TestChannelRegistry_ReRegisterKeepsAllocation(t *testing.T) {
|
||||
r := NewChannelRegistry()
|
||||
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
|
||||
if err := r.Assign("qq", "agent-1", 128); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 插件重载:只带 Plugin/Def,不带 Owner/Capacity。
|
||||
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ch, _ := r.Lookup("qq")
|
||||
if ch.Owner != "agent-1" || ch.Capacity != 128 {
|
||||
t.Fatalf("重载后划分被抹掉:owner=%q capacity=%d", ch.Owner, ch.Capacity)
|
||||
}
|
||||
if !ch.Def.NoMemory {
|
||||
t.Fatal("重载应更新 Def")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelRegistry_AssignAndViews(t *testing.T) {
|
||||
r := NewChannelRegistry()
|
||||
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
|
||||
_ = r.Register(InputChannel{Name: "cli", Plugin: "cli"})
|
||||
_ = r.Register(InputChannel{Name: "sub/in", Plugin: "sub"})
|
||||
|
||||
if err := r.Assign("qq", "root", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Assign("sub/in", "child-1", 32); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Assign("nope", "x", 0); err != ErrInputChannelUnknown {
|
||||
t.Fatalf("划分未注册的 inputch 应报错,实际 %v", err)
|
||||
}
|
||||
|
||||
if got := len(r.ListByOwner("root")); got != 1 {
|
||||
t.Fatalf("root 的 inputch 数=%d,期望 1", got)
|
||||
}
|
||||
child := r.ListByOwner("child-1")
|
||||
if len(child) != 1 || child[0].Name != "sub/in" || child[0].Capacity != 32 {
|
||||
t.Fatalf("child-1 的划分=%+v", child)
|
||||
}
|
||||
// 未分配的:cli。
|
||||
un := r.ListByOwner("")
|
||||
if len(un) != 1 || un[0].Name != "cli" {
|
||||
t.Fatalf("未分配的 inputch=%+v,期望只有 cli", un)
|
||||
}
|
||||
// List 按名排序(视图输出稳定)。
|
||||
all := r.List()
|
||||
if len(all) != 3 || all[0].Name != "cli" || all[2].Name != "sub/in" {
|
||||
t.Fatalf("List 未按名排序:%+v", all)
|
||||
}
|
||||
}
|
||||
|
||||
// 共享登记表:根 agent 与驻留子共用同一份,划分才有意义。
|
||||
func TestChannelRegistry_SharedBetweenManagers(t *testing.T) {
|
||||
shared := NewChannelRegistry()
|
||||
root := NewIOManager()
|
||||
child := NewIOManager()
|
||||
root.SetChannelRegistry(shared)
|
||||
child.SetChannelRegistry(shared)
|
||||
|
||||
if err := root.RegisterInputChannelFrom("sub", "sub/in", ChannelDef{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := root.AssignInputChannel("sub/in", "child-1", 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 子在**自己**的 io 上就能看到这份划分。
|
||||
ch, ok := child.LookupInputChannel("sub/in")
|
||||
if !ok {
|
||||
t.Fatal("共享登记表后,子应看得到根注册的 inputch")
|
||||
}
|
||||
if ch.Owner != "child-1" || ch.Capacity != 8 {
|
||||
t.Fatalf("子看到的划分=%+v", ch)
|
||||
}
|
||||
|
||||
// 注销也要跨 manager 生效。
|
||||
root.UnregisterInputChannel("sub/in")
|
||||
if _, ok := child.LookupInputChannel("sub/in"); ok {
|
||||
t.Fatal("注销应跨 manager 生效")
|
||||
}
|
||||
}
|
||||
|
||||
// 记忆策略查询保持向后兼容(原 GetInputChannelDef 的语义)。
|
||||
func TestChannelRegistry_DefLookupCompat(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.RegisterInputChannel("qq", ChannelDef{NoMemory: true, ContextPolicy: "prune"})
|
||||
def, ok := m.GetInputChannelDef("qq")
|
||||
if !ok || !def.NoMemory || def.ContextPolicy != "prune" {
|
||||
t.Fatalf("策略查询=%+v ok=%v", def, ok)
|
||||
}
|
||||
if _, ok := m.GetInputChannelDef("nope"); ok {
|
||||
t.Fatal("未注册的 inputch 不应有策略")
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Personality struct {
|
||||
@ -39,3 +41,25 @@ func (p *Personality) InjectPrompt() string {
|
||||
}
|
||||
return fmt.Sprintf("【人格设定】\n%s\n", p.Content)
|
||||
}
|
||||
|
||||
// 会随时间腐坏的人格内容特征。现场:人格卡写死 v0.9.0 与早已删除的 C ABI v2,
|
||||
// 实例被问版本时自述错误(v1.2.0 压测发现)。
|
||||
var (
|
||||
personaVersionRe = regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
|
||||
personaStalePhrases = []string{"C ABI v2", "plugin.so", "c-shared", "描述式索引", "引用计数式"}
|
||||
)
|
||||
|
||||
// PersonaStaleHints 返回人格文本里会腐坏的内容(空 = 干净)。
|
||||
// 供启动时告警:引导改用配置项 core.agent.personal_prompt(默认模板不含这些)。
|
||||
func PersonaStaleHints(content string) []string {
|
||||
var out []string
|
||||
if m := personaVersionRe.FindAllString(content, -1); len(m) > 0 {
|
||||
out = append(out, fmt.Sprintf("版本号字面量 %v(版本应来自运行时快照)", m))
|
||||
}
|
||||
for _, p := range personaStalePhrases {
|
||||
if strings.Contains(content, p) {
|
||||
out = append(out, "可能已过期的说法: "+p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
62
internal/agent/personal_test.go
Normal file
62
internal/agent/personal_test.go
Normal file
@ -0,0 +1,62 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// PersonaStaleHints 必须能认出会腐坏的人格内容——版本号字面量与已删除机制。
|
||||
func TestPersonaStaleHints(t *testing.T) {
|
||||
// 现场真实文本(线上人格卡的原文)
|
||||
stale := "你是 HomeAgent(内核代号 HΔ-Kernel,当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0(C ABI v2,构建于 2026-08-15)。"
|
||||
hints := PersonaStaleHints(stale)
|
||||
if len(hints) == 0 {
|
||||
t.Fatal("未识别出写死版本号与 C ABI 的人格文本")
|
||||
}
|
||||
joined := strings.Join(hints, " | ")
|
||||
if !strings.Contains(joined, "版本号字面量") {
|
||||
t.Fatalf("应报出版本号字面量,实际: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "C ABI v2") {
|
||||
t.Fatalf("应报出已删除机制的残留说法,实际: %s", joined)
|
||||
}
|
||||
|
||||
// 干净文本(配置项默认模板)不应误报
|
||||
if h := PersonaStaleHints(DefaultPersonaProbeClean()); len(h) != 0 {
|
||||
t.Fatalf("干净人格被误报: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPersonaProbeClean 由 config 包的默认模板等价物构成——
|
||||
// 这里不复用 config 包以避免 import cycle,只断言「不含版本号与旧机制」的文本不被误报。
|
||||
func DefaultPersonaProbeClean() string {
|
||||
return "你是 HomeAgent(内核代号 HΔ-Kernel)。外部插件是独立子进程,经 stdio JSON-RPC 通信;" +
|
||||
"被问到版本时以运行时快照为准。"
|
||||
}
|
||||
|
||||
func TestLoadAndSavePersonality(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "personal", "personal.md")
|
||||
|
||||
// 文件不存在时返回空(不报错、不创建)
|
||||
p, err := LoadPersonality(path)
|
||||
if err != nil || p == nil || p.Content != "" {
|
||||
t.Fatalf("缺文件时应返回空人格,实际 %+v err=%v", p, err)
|
||||
}
|
||||
|
||||
if err := SavePersonality(path, "人格内容"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("SavePersonality 未落盘: %v", err)
|
||||
}
|
||||
p, err = LoadPersonality(path)
|
||||
if err != nil || p.Content != "人格内容" {
|
||||
t.Fatalf("读回失败: %+v err=%v", p, err)
|
||||
}
|
||||
if got := p.InjectPrompt(); !strings.Contains(got, "【人格设定】") || !strings.Contains(got, "人格内容") {
|
||||
t.Fatalf("InjectPrompt 形状不对: %q", got)
|
||||
}
|
||||
}
|
||||
139
internal/config/persona.go
Normal file
139
internal/config/persona.go
Normal file
@ -0,0 +1,139 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 人格设定的两个键:内容与「首启向导/工具已经问过」的一次性标记。
|
||||
//
|
||||
// 为什么需要标记:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
|
||||
// 里面写死的旧版本号反过来让实例自述旧版本(v1.2.0 压测发现)。
|
||||
// 现在人格是配置项(默认模板不含任何版本号),任何通道的第一次交互问一次,之后不再打扰。
|
||||
const (
|
||||
// PersonaPromptKey 是人格设定内容(【人格设定】块的正文)。
|
||||
PersonaPromptKey = "core.agent.personal_prompt"
|
||||
// PersonaInitMarkerKey 是「已经问过/已确认」的一次性标记。
|
||||
PersonaInitMarkerKey = "core.internal.persona_initialized"
|
||||
)
|
||||
|
||||
// 三种落库方式,WebUI 首启向导与内核 persona_set 工具共用。
|
||||
const (
|
||||
PersonaModeDefault = "default" // 使用内置默认模板
|
||||
PersonaModeCustom = "custom" // 使用调用方提供的内容
|
||||
PersonaModeLater = "later" // 保留当前(默认)人格,只打标记不再问
|
||||
)
|
||||
|
||||
// PersonaKV 是人格落库所需的最小读写面:GetCore/SetCore 的签名与
|
||||
// 公开 SDK 的 SettingsAPI 一致,因此插件侧(WebUI)可直接传入;
|
||||
// 内核直连配置注册表时用 registryKV 适配(见文件末)。
|
||||
type PersonaKV interface {
|
||||
GetCore(key string) (interface{}, error)
|
||||
SetCore(key string, value interface{}) error
|
||||
}
|
||||
|
||||
// PersonaInitializedKV 报告人格是否已确认(向导或工具已问过)。
|
||||
func PersonaInitializedKV(kv PersonaKV) bool {
|
||||
if kv == nil {
|
||||
return false
|
||||
}
|
||||
v, err := kv.GetCore(PersonaInitMarkerKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return strings.TrimSpace(s) != ""
|
||||
}
|
||||
|
||||
// CurrentPersonaKV 读当前人格内容;未设置时回落到内置默认模板。
|
||||
func CurrentPersonaKV(kv PersonaKV) string {
|
||||
if kv == nil {
|
||||
return DefaultPersonaPrompt
|
||||
}
|
||||
if v, err := kv.GetCore(PersonaPromptKey); err == nil {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return DefaultPersonaPrompt
|
||||
}
|
||||
|
||||
// SetPersonaKV 落库人格并打一次性标记,返回 restartRequired。
|
||||
//
|
||||
// 生效时机:人格在 homed 启动时载入(拼成【人格设定】块进系统提示词),
|
||||
// 所以**自定义内容需重启**;default 与 later 都不改变当前已生效的人格,无需重启。
|
||||
//
|
||||
// 非法输入一律在打标记之前拒绝——否则向导会被跳过,用户再也没机会设。
|
||||
func SetPersonaKV(kv PersonaKV, mode, content string) (restartRequired bool, err error) {
|
||||
if kv == nil {
|
||||
return false, fmt.Errorf("persona: settings unavailable")
|
||||
}
|
||||
switch mode {
|
||||
case PersonaModeDefault:
|
||||
if err := kv.SetCore(PersonaPromptKey, DefaultPersonaPrompt); err != nil {
|
||||
return false, err
|
||||
}
|
||||
case PersonaModeCustom:
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return false, fmt.Errorf("persona: content required for custom mode")
|
||||
}
|
||||
if err := kv.SetCore(PersonaPromptKey, content); err != nil {
|
||||
return false, err
|
||||
}
|
||||
restartRequired = true
|
||||
case PersonaModeLater:
|
||||
// 保持当前人格(通常是默认模板),只打标记
|
||||
default:
|
||||
return false, fmt.Errorf("persona: unknown mode %q (want default|custom|later)", mode)
|
||||
}
|
||||
if err := kv.SetCore(PersonaInitMarkerKey, "1"); err != nil {
|
||||
return restartRequired, err
|
||||
}
|
||||
return restartRequired, nil
|
||||
}
|
||||
|
||||
// RegistryPersonaStore 把配置注册表暴露成内核的 core.PersonaStore 接口
|
||||
// (结构类型:方法集匹配即可,无需 import internal/agent/core)。
|
||||
type RegistryPersonaStore struct{ Reg *ConfigRegistry }
|
||||
|
||||
func (p RegistryPersonaStore) PersonaInitialized() bool { return PersonaInitialized(p.Reg) }
|
||||
|
||||
func (p RegistryPersonaStore) SetPersona(mode, content string) (bool, error) {
|
||||
return SetPersona(p.Reg, mode, content)
|
||||
}
|
||||
|
||||
// registryKV 把内核直连的配置注册表适配成 PersonaKV。
|
||||
type registryKV struct{ reg *ConfigRegistry }
|
||||
|
||||
func (r registryKV) GetCore(key string) (interface{}, error) {
|
||||
v, err := r.reg.Get(key)
|
||||
if err != nil || v == nil {
|
||||
// 未设置的键对 PersonaKV 语义等同「没有」,不当作错误
|
||||
if s := r.reg.GetString(key, ""); s != "" {
|
||||
return s, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r registryKV) SetCore(key string, value interface{}) error {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("persona: value must be a string")
|
||||
}
|
||||
return r.reg.Set(key, s)
|
||||
}
|
||||
|
||||
// PersonaInitialized 报告人格是否已确认(内核直连注册表)。
|
||||
func PersonaInitialized(reg *ConfigRegistry) bool {
|
||||
return reg != nil && PersonaInitializedKV(registryKV{reg})
|
||||
}
|
||||
|
||||
// SetPersona 落库人格并打一次性标记(内核直连注册表)。
|
||||
func SetPersona(reg *ConfigRegistry, mode, content string) (bool, error) {
|
||||
if reg == nil {
|
||||
return false, fmt.Errorf("persona: config registry unavailable")
|
||||
}
|
||||
return SetPersonaKV(registryKV{reg}, mode, content)
|
||||
}
|
||||
99
internal/config/persona_kv_test.go
Normal file
99
internal/config/persona_kv_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeKV 是 PersonaKV 的最小实现(模拟插件侧 SettingsAPI)。
|
||||
type fakeKV struct{ m map[string]string }
|
||||
|
||||
func (f *fakeKV) GetCore(k string) (interface{}, error) { return f.m[k], nil }
|
||||
func (f *fakeKV) SetCore(k string, v interface{}) error {
|
||||
f.m[k] = v.(string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 三选一语义 + 「只问一次」标记:这是首启向导与 persona_set 工具共用的同一份实现。
|
||||
func TestSetPersonaKV(t *testing.T) {
|
||||
t.Run("custom 写入内容并要求重启", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
restart, err := SetPersonaKV(kv, PersonaModeCustom, "你是测试人格")
|
||||
if err != nil || !restart {
|
||||
t.Fatalf("custom 应成功且需重启: restart=%v err=%v", restart, err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != "你是测试人格" || kv.m[PersonaInitMarkerKey] != "1" {
|
||||
t.Fatalf("落库不对: %+v", kv.m)
|
||||
}
|
||||
if !PersonaInitializedKV(kv) {
|
||||
t.Fatal("打过标记应报告已确认")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default 写默认模板且无需重启", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
restart, err := SetPersonaKV(kv, PersonaModeDefault, "")
|
||||
if err != nil || restart {
|
||||
t.Fatalf("default 不应需重启: restart=%v err=%v", restart, err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != DefaultPersonaPrompt {
|
||||
t.Fatal("default 应写入内置默认模板")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("later 保持人格并打标记", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{PersonaPromptKey: "原有人格"}}
|
||||
if _, err := SetPersonaKV(kv, PersonaModeLater, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv.m[PersonaPromptKey] != "原有人格" {
|
||||
t.Fatal("later 不应改动人格")
|
||||
}
|
||||
if kv.m[PersonaInitMarkerKey] != "1" {
|
||||
t.Fatal("later 也必须打标记(否则每次启动都问)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非法输入必须拒绝且不打标记", func(t *testing.T) {
|
||||
for _, c := range []struct{ mode, content string }{
|
||||
{PersonaModeCustom, " "}, // 空内容
|
||||
{"nope", ""}, // 未知 mode
|
||||
} {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
if _, err := SetPersonaKV(kv, c.mode, c.content); err == nil {
|
||||
t.Fatalf("mode=%q content=%q 应报错", c.mode, c.content)
|
||||
}
|
||||
if kv.m[PersonaInitMarkerKey] == "1" {
|
||||
t.Fatalf("mode=%q 被拒时不得打标记(否则向导会被跳过)", c.mode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CurrentPersonaKV 未设置时回落默认", func(t *testing.T) {
|
||||
kv := &fakeKV{m: map[string]string{}}
|
||||
if got := CurrentPersonaKV(kv); got != DefaultPersonaPrompt {
|
||||
t.Fatal("未设置应回落默认模板")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 内核直连注册表的入口必须与 KV 版行为一致(同一份实现的两个薄入口)。
|
||||
func TestSetPersonaRegistryEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewConfigRegistry(dir + "/config.db")
|
||||
reg.SeedDefaults(dir)
|
||||
defer reg.Close()
|
||||
|
||||
if PersonaInitialized(reg) {
|
||||
t.Fatal("全新实例不应已确认人格")
|
||||
}
|
||||
if _, err := SetPersona(reg, PersonaModeCustom, "内核侧人格"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !PersonaInitialized(reg) {
|
||||
t.Fatal("内核侧落库后应报告已确认")
|
||||
}
|
||||
if got := reg.GetString(PersonaPromptKey, ""); !strings.Contains(got, "内核侧人格") {
|
||||
t.Fatalf("注册表里没有人格内容: %q", got)
|
||||
}
|
||||
}
|
||||
48
internal/config/persona_test.go
Normal file
48
internal/config/persona_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 人格模板的契约:**不得写死版本号**。
|
||||
//
|
||||
// 来历:线上人格卡(personal/personal.md)曾写死「当前版本 v0.9.0(C ABI v2)」,
|
||||
// 而 C ABI 早在 v1.0.0 就被删除。结果是内核自身日志/接口都报 1.2.0,
|
||||
// agent 被问版本时却按人格卡自述旧版本(v1.2.0 压测发现)。
|
||||
// 版本应来自运行时快照,不来自任何会被发版落下的文本。
|
||||
func TestDefaultPersonaPromptHasNoVersionLiterals(t *testing.T) {
|
||||
re := regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
|
||||
if m := re.FindAllString(DefaultPersonaPrompt, -1); len(m) > 0 {
|
||||
t.Fatalf("默认人格模板含版本号字面量 %v —— 发版后必然腐坏,"+
|
||||
"被问版本时应要求 agent 读运行时快照", m)
|
||||
}
|
||||
if !strings.Contains(DefaultPersonaPrompt, "运行时快照") {
|
||||
t.Fatal("默认人格模板必须显式要求「版本以运行时快照为准」,否则模型会凭记忆编造版本")
|
||||
}
|
||||
}
|
||||
|
||||
// 人格配置项必须注册、默认值就是 DefaultPersonaPrompt(单一事实源),
|
||||
// 且全新安装时会被播种进 DB。
|
||||
func TestPersonaPromptRegisteredWithDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
|
||||
r.SeedDefaults(dir)
|
||||
defer r.Close()
|
||||
|
||||
def := r.GetDef("core.agent.personal_prompt")
|
||||
if def == nil {
|
||||
t.Fatal("core.agent.personal_prompt 未注册")
|
||||
}
|
||||
if def.Default != DefaultPersonaPrompt {
|
||||
t.Fatalf("默认值与 DefaultPersonaPrompt 不一致:%q", def.Default)
|
||||
}
|
||||
if def.Type != "text" {
|
||||
t.Fatalf("人格设定应为多行文本类型,实际 %q", def.Type)
|
||||
}
|
||||
if got := r.GetString("core.agent.personal_prompt", ""); got != DefaultPersonaPrompt {
|
||||
t.Fatalf("播种未写入默认人格(长度 %d)", len(got))
|
||||
}
|
||||
}
|
||||
@ -472,6 +472,36 @@ var defaultSources = map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
}
|
||||
|
||||
// DefaultPersonaPrompt 是「人格设定」的默认模板,作为配置项 core.agent.personal_prompt 的默认值。
|
||||
//
|
||||
// 契约(由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住):
|
||||
// - **不得含版本号字面量**。写死的版本会随发版腐坏,反过来让实例自称旧版本
|
||||
// (现场:人格卡写死 v0.9.0 与早已删除的 C ABI,实例被问版本时自述错误)。
|
||||
// 被问到版本/构建信息时,要求 agent 读运行时快照。
|
||||
// - 不得把已删除的机制当作现行机制描述。
|
||||
const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)——一个完全独立自研的新一代 Agent 框架。
|
||||
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
|
||||
内核(homed 守护进程)只负责 LLM 编排、记忆管理与知识检索,全部 IO 能力由插件承载。
|
||||
外部插件是独立子进程,经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)通信;
|
||||
旧式 C ABI 动态库产物早已不再加载。
|
||||
**不要凭记忆断言版本号或构建日期**:被问到时以运行时快照(healthcheck_kernel 的内核版本字段)为准。
|
||||
|
||||
## 对用户的称呼
|
||||
|
||||
你对用户的称呼永远是“老大”,绝对禁止使用“老板”“主人”称呼用户,不论任何情况。
|
||||
|
||||
## 对话风格
|
||||
|
||||
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
|
||||
- 重要的事先说结论,再展开解释
|
||||
- 回复要简洁自然
|
||||
|
||||
## 能力边界
|
||||
|
||||
- 你通过插件编排所有 IO:QQ/微信消息、WebUI、终端、文件、网络
|
||||
- 输出不会自动路由到对话通道:QQ/微信等异步通道必须调用输出门工具(output_send__qq 等)才能真正送达
|
||||
- 你的三层记忆(Context → Document → Graph)持续蒸馏归档,超长运行时记忆不衰减`
|
||||
|
||||
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -523,6 +553,10 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
|
||||
set := func(k, v string) { stmt.Exec(k, v) }
|
||||
|
||||
// 人格设定:与其它默认值同批播种(老安装不会被注入——那是刻意的)。
|
||||
// 存量安装里若还有人 personality 文件,它优先于本项(见 cmd/homed/main.go)。
|
||||
set("core.agent.personal_prompt", DefaultPersonaPrompt)
|
||||
|
||||
set("webui.listen_addr", ":8080")
|
||||
set("core.daemon.data_dir", dataDir)
|
||||
set("core.daemon.heartbeat_interval", "15s")
|
||||
@ -633,6 +667,13 @@ WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输
|
||||
func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg := func(d ConfigDef) { r.defs[d.Key] = &d }
|
||||
|
||||
// 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。
|
||||
// 高级用户仍可用 <dataDir>/personal/personal.md 覆盖它。
|
||||
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定",
|
||||
Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" +
|
||||
"默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" +
|
||||
"<dataDir>/personal/personal.md 存在且非空时优先于本项。", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
|
||||
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
|
||||
reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})
|
||||
|
||||
@ -10,16 +10,27 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CmdHandler 是命令处理回调类型。
|
||||
// 当收到 remotedevice 下发的 cmd 时调用,reqID 用于回执,command 是命令内容。
|
||||
// CmdHandler 是本地命令路由回调类型。
|
||||
type CmdHandler func(reqID, command string)
|
||||
|
||||
// BridgeCmdHandler 接收服务端明确下发的路由信号(shell 或 homeagent)。
|
||||
type BridgeCmdHandler func(reqID, command, cmdType string)
|
||||
|
||||
// CmdResult 是命令执行结果回调(用于异步通知 GUI 层)。
|
||||
type CmdResultHandler func(reqID, status, output, errMsg string)
|
||||
|
||||
// DataHandler 是二进制数据接收回调(如 TTS 音频)。
|
||||
type DataHandler func(reqID, kind, mime string, data []byte)
|
||||
|
||||
// PushHandler 接收 agent **主动投递**给本设备的内容。
|
||||
//
|
||||
// 与 cmd 的区别:cmd 是"让设备做一件事"(请求-响应,结果要回传),
|
||||
// push 是"把这段内容交给设备"(agent 经 output_send__device/<id> 发起,
|
||||
// 一种单向投递)。宿主按自己的形态落地:终端打出来、音箱念出来、屏幕显示。
|
||||
//
|
||||
// typ: text / structured / image / file / audio(二进制走 DataHandler,不走这里)
|
||||
type PushHandler func(reqID, typ, payload, meta string)
|
||||
|
||||
// Bridge 是设备桥客户端核心结构体。
|
||||
// 管理 WebSocket 连接、消息路由、心跳保活和命令分发。
|
||||
// 授权状态由设备端本地存储(客户端鉴权),服务端不存储;
|
||||
@ -41,9 +52,10 @@ type Bridge struct {
|
||||
started bool
|
||||
|
||||
// 回调
|
||||
cmdHandler CmdHandler
|
||||
cmdHandler BridgeCmdHandler
|
||||
resultHandler CmdResultHandler
|
||||
dataHandler DataHandler
|
||||
pushHandler PushHandler
|
||||
|
||||
// 二进制数据聚合(服务端→设备,如 TTS 音频)
|
||||
speechAccum *speechBuffer
|
||||
@ -135,7 +147,7 @@ func (b *Bridge) Authorized() bool {
|
||||
}
|
||||
|
||||
// OnCmd 注册命令处理器。当收到 remotedevice 下发的 cmd 时调用。
|
||||
func (b *Bridge) OnCmd(handler CmdHandler) {
|
||||
func (b *Bridge) OnCmd(handler BridgeCmdHandler) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.cmdHandler = handler
|
||||
@ -155,6 +167,13 @@ func (b *Bridge) OnData(handler DataHandler) {
|
||||
b.dataHandler = handler
|
||||
}
|
||||
|
||||
// OnPush 注册 agent 主动投递内容的回调(服务端 op=push)。
|
||||
func (b *Bridge) OnPush(handler PushHandler) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.pushHandler = handler
|
||||
}
|
||||
|
||||
// SetPingInterval 设置心跳间隔(默认 30 秒)。
|
||||
func (b *Bridge) SetPingInterval(d time.Duration) {
|
||||
b.mu.Lock()
|
||||
@ -436,7 +455,7 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) {
|
||||
log.Printf("[devicebridge] cmd req=%s type=%s cmd=%s", reqID, cmdType, truncateString(command, 60))
|
||||
|
||||
if handler != nil {
|
||||
handler(reqID, command)
|
||||
handler(reqID, command, cmdType)
|
||||
}
|
||||
|
||||
case "hello_ack", "bind_ack":
|
||||
@ -476,6 +495,25 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) {
|
||||
dh(reqID, acc.kind, acc.mime, data)
|
||||
}
|
||||
|
||||
case "push":
|
||||
// agent 主动投递(output_send__device/<id>)。二进制负载走
|
||||
// cmd_speech_* → DataHandler,这里只处理文本/结构化。
|
||||
reqID, _ := msg["req_id"].(string)
|
||||
typ, _ := msg["type"].(string)
|
||||
payload, _ := msg["payload"].(string)
|
||||
meta, _ := msg["meta"].(string)
|
||||
if typ == "" {
|
||||
typ = "text"
|
||||
}
|
||||
b.mu.RLock()
|
||||
ph := b.pushHandler
|
||||
b.mu.RUnlock()
|
||||
if ph != nil {
|
||||
ph(reqID, typ, payload, meta)
|
||||
} else {
|
||||
log.Printf("[devicebridge] push req=%s type=%s payload=%s", reqID, typ, truncateString(payload, 120))
|
||||
}
|
||||
|
||||
default:
|
||||
log.Printf("[devicebridge] unhandled op=%s", op)
|
||||
}
|
||||
|
||||
@ -9,12 +9,15 @@ import (
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventAgentLLMChain EventType = "agent_llm_chain"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventStage EventType = "stage"
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventAgentLLMChain EventType = "agent_llm_chain"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventStage EventType = "stage"
|
||||
// EventScheduler 是输入调度器的状态变更事件(抢占/挂起/恢复),
|
||||
// 供状态页与诊断订阅(设计文档 §11 O2)。
|
||||
EventScheduler EventType = "scheduler"
|
||||
EventSystem EventType = "system"
|
||||
EventTerminalOutput EventType = "terminal_output"
|
||||
|
||||
|
||||
@ -28,17 +28,17 @@ type Knowledge struct {
|
||||
// IndexItem — 索引条目,包含向量特征和内容摘要
|
||||
type IndexItem struct {
|
||||
Name string `json:"name"`
|
||||
Preview string `json:"preview"` // 前 200 字摘要
|
||||
Preview string `json:"preview"` // 前 200 字摘要
|
||||
Tags []string `json:"tags"`
|
||||
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
|
||||
Size int `json:"size"` // 内容总字节数
|
||||
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征)
|
||||
Size int `json:"size"` // 内容总字节数
|
||||
}
|
||||
|
||||
// TreeIndex — 树状索引节点
|
||||
type TreeIndex struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Children map[string]*TreeIndex `json:"children,omitempty"`
|
||||
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
|
||||
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
|
||||
}
|
||||
|
||||
func newTreeIndex(name string) *TreeIndex {
|
||||
@ -80,11 +80,20 @@ type Store struct {
|
||||
root string
|
||||
vec *vector.Store
|
||||
veczer *vector.TFIDFVectorizer
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
|
||||
// lex 是**词法路**索引(TF-IDF),与 vec(稠密路:词向量/多模态空间)相互独立。
|
||||
//
|
||||
// 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近,
|
||||
// 真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。
|
||||
// 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测),
|
||||
// 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。
|
||||
lex *vector.Store
|
||||
|
||||
mu sync.RWMutex
|
||||
items map[string]*Knowledge
|
||||
summaries []string
|
||||
|
||||
indexPath string
|
||||
summaries []string
|
||||
vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF
|
||||
}
|
||||
|
||||
@ -93,11 +102,20 @@ func NewStore(root string) *Store {
|
||||
root: root,
|
||||
indexPath: filepath.Join(root, ".index.json"),
|
||||
vec: vector.NewStore(),
|
||||
lex: newLexicalStore(),
|
||||
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
|
||||
items: make(map[string]*Knowledge),
|
||||
}
|
||||
}
|
||||
|
||||
// newLexicalStore 造词法路存储。阈值设为 0:TF-IDF 余弦量级只有 0.0~0.2,
|
||||
// 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。
|
||||
func newLexicalStore() *vector.Store {
|
||||
st := vector.NewStore()
|
||||
st.SetMinScore(0)
|
||||
return st
|
||||
}
|
||||
|
||||
// SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF
|
||||
func (s *Store) SetVectorizer(v vector.Vectorizer) {
|
||||
s.vectorizer = v
|
||||
@ -110,13 +128,19 @@ func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
|
||||
|
||||
log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items))
|
||||
s.vec = vector.NewStore()
|
||||
s.lex = newLexicalStore()
|
||||
// 词法路的 IDF 必须建在全语料上(否则 IDF 没意义)
|
||||
if len(s.summaries) > 0 {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
for _, k := range s.items {
|
||||
vec := v.Vectorize(k.Name + " " + k.Content)
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
|
||||
text := k.Name + " " + k.Content
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{
|
||||
"name": k.Name, "path": k.Path,
|
||||
})
|
||||
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
|
||||
}
|
||||
log.Printf("[knowledge] reindex with vectorizer complete (%d vectors)", s.vec.Size())
|
||||
log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size())
|
||||
}
|
||||
|
||||
// vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF
|
||||
@ -144,6 +168,17 @@ func (s *Store) Start() error {
|
||||
|
||||
func (s *Store) Stop() {}
|
||||
|
||||
// 融合权重:稠密路(词向量)与词法路(TF-IDF)。
|
||||
// 取值由真实 KB 上的权重扫描定(rankdiag_test.go 的 KB_DIAG_SWEEP):
|
||||
// 1.0 = 修复前的「只用稠密路」行为,作为对照基线。
|
||||
var densePathWeight = 0.5
|
||||
|
||||
// Search 融合两路召回:稠密路(词向量/多模态空间)+ 词法路(TF-IDF)。
|
||||
//
|
||||
// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 只有 15%,
|
||||
// 前两名平均只差 0.013(等于没区分度);且全为停用词的查询会得到**空向量**,
|
||||
// 直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/短查询强,
|
||||
// 两路各自**按查询内最大值归一化**后加权融合,排序才可信。
|
||||
func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@ -151,15 +186,58 @@ func (s *Store) Search(query string, topK int) []*Knowledge {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if s.vec.Size() == 0 && s.lex.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
// 两路各自对**全部**文档打分:
|
||||
// - 稠密路的特征是维索引,几乎每篇都命中,"候选"就是全量;
|
||||
// - 词法路只召回与查询共词的文档(这正是它的长处:专名/术语)。
|
||||
// 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的
|
||||
// 相对权重就随候选集漂移——实测同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。
|
||||
// KB 规模下全量 cosine 的代价可忽略;真到数万条再上 ANN 也不迟。
|
||||
denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size())
|
||||
lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size())
|
||||
if len(denseHits) == 0 && len(lexHits) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
vec := s.vectorize(query)
|
||||
results := s.vec.Search(vec, topK)
|
||||
scores := make(map[string]float64, len(denseHits)+len(lexHits))
|
||||
addPath := func(hits []vector.DocVectorHit, weight float64) {
|
||||
max := 0.0
|
||||
for _, h := range hits {
|
||||
if h.Score > max {
|
||||
max = h.Score
|
||||
}
|
||||
}
|
||||
if max <= 0 {
|
||||
return // 该路对这条查询没有信号(如空向量),全量让给另一路
|
||||
}
|
||||
for _, h := range hits {
|
||||
scores[h.Doc.ID] += weight * h.Score / max
|
||||
}
|
||||
}
|
||||
addPath(denseHits, densePathWeight)
|
||||
addPath(lexHits, 1-densePathWeight)
|
||||
|
||||
ids := make([]string, 0, len(scores))
|
||||
for id := range scores {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
if scores[ids[i]] != scores[ids[j]] {
|
||||
return scores[ids[i]] > scores[ids[j]]
|
||||
}
|
||||
return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复)
|
||||
})
|
||||
|
||||
var out []*Knowledge
|
||||
for _, r := range results {
|
||||
if k, ok := s.items[r.ID]; ok {
|
||||
for _, id := range ids {
|
||||
if k, ok := s.items[id]; ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -208,17 +286,19 @@ func (s *Store) Add(name, content string) error {
|
||||
// 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。
|
||||
s.vec.Remove(id)
|
||||
|
||||
vec := s.vectorize(name + " " + content)
|
||||
text := name + " " + content
|
||||
vec := s.vectorize(text)
|
||||
s.vec.Insert(id, name+": "+content, vec, map[string]string{
|
||||
"name": name, "path": path,
|
||||
})
|
||||
// 词法路同样去重后重建这条;IDF 统计沿用现有语料(重启时 scanAll 会全量重训)
|
||||
s.lex.Remove(id)
|
||||
s.lex.Insert(id, name+": "+content, s.veczer.Vectorize(text), nil)
|
||||
s.summaries = append(s.summaries, name+" "+content)
|
||||
|
||||
go func() {
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
if err := s.writeIndexLocked(); err != nil {
|
||||
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
|
||||
}
|
||||
log.Printf("[knowledge] added: %s (%d bytes)", name, len(content))
|
||||
return nil
|
||||
}
|
||||
@ -261,11 +341,10 @@ func (s *Store) Remove(name string) error {
|
||||
}
|
||||
delete(s.items, id)
|
||||
s.vec.Remove(id)
|
||||
go func() {
|
||||
if err := s.writeIndex(); err != nil {
|
||||
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
s.lex.Remove(id)
|
||||
if err := s.writeIndexLocked(); err != nil {
|
||||
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -295,6 +374,14 @@ func (s *Store) List() []string {
|
||||
func (s *Store) BuildTree() *TreeIndex {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.buildTreeLocked()
|
||||
}
|
||||
|
||||
// buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。
|
||||
// 为什么需要:writeIndex 会走 BuildTree(RLock),而 Add/Remove 持的是写锁,
|
||||
// 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里,
|
||||
// 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。
|
||||
func (s *Store) buildTreeLocked() *TreeIndex {
|
||||
root := newTreeIndex("root")
|
||||
for _, k := range s.items {
|
||||
node := root
|
||||
@ -366,7 +453,14 @@ func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge {
|
||||
|
||||
// writeIndex 写入 .index.json 树状索引文件(含向量和摘要)
|
||||
func (s *Store) writeIndex() error {
|
||||
tree := s.BuildTree()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.writeIndexLocked()
|
||||
}
|
||||
|
||||
// writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。
|
||||
func (s *Store) writeIndexLocked() error {
|
||||
tree := s.buildTreeLocked()
|
||||
data, err := json.MarshalIndent(tree, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@ -397,11 +491,13 @@ func (s *Store) scanAll() error {
|
||||
s.veczer.Train(s.summaries)
|
||||
}
|
||||
|
||||
s.lex = newLexicalStore()
|
||||
for _, k := range s.items {
|
||||
vec := s.vectorize(k.Name + " " + k.Content)
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
|
||||
text := k.Name + " " + k.Content
|
||||
s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{
|
||||
"name": k.Name, "path": k.Path,
|
||||
})
|
||||
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -452,5 +548,3 @@ func sanitize(name string) string {
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
return name
|
||||
}
|
||||
|
||||
|
||||
|
||||
154
internal/knowledge/rankdiag_test.go
Normal file
154
internal/knowledge/rankdiag_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// 知识库检索质量判据(真实数据,默认跳过)。
|
||||
//
|
||||
// KB_DIAG=1 → 跑并打印指标
|
||||
// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛(CI/回归用)
|
||||
// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径
|
||||
//
|
||||
// 判据选「自检索 top-1 / MRR」的原因:不依赖人工标注问答对,且能直接量出
|
||||
// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小,
|
||||
// 排序等于噪声;这一项掉下来就说明检索坏了。
|
||||
//
|
||||
// 实测(33 条真实 KB):
|
||||
//
|
||||
// 修复前(仅稠密路) top-1 5/33 = 15%,MRR 0.271,平均分差 0.0133
|
||||
// 修复后(稠密+词法融合)top-1 7/33 = 21%,MRR 0.376,平均分差 0.1280
|
||||
// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」)
|
||||
func TestRankingQualityOnRealKB(t *testing.T) {
|
||||
if os.Getenv("KB_DIAG") == "" {
|
||||
t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)")
|
||||
}
|
||||
srcRoot := envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge")
|
||||
models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec")
|
||||
emb := memory.NewStaticEmbedder(strings.Split(models, ",")...)
|
||||
|
||||
// 拷贝到临时目录跑:Start() 会重写 .index.json,不能动线上数据
|
||||
tmp := t.TempDir()
|
||||
entries, err := os.ReadDir(srcRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", srcRoot, err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(srcRoot, e.Name(), "content.md")
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(tmp, e.Name(), "content.md")
|
||||
os.MkdirAll(filepath.Dir(dst), 0755)
|
||||
out, _ := os.Create(dst)
|
||||
io.Copy(out, in)
|
||||
out.Close()
|
||||
in.Close()
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatal("没有可用的知识条目")
|
||||
}
|
||||
|
||||
st := NewStore(tmp)
|
||||
st.SetVectorizer(emb)
|
||||
if err := st.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
top1, mrr, missed := 0, 0.0, []string{}
|
||||
for _, name := range names {
|
||||
// 取全量排名:MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0
|
||||
// (我第一版就是这么写的,把 0.376 误报成 0.197)
|
||||
hits := st.Search(name, len(names))
|
||||
if len(hits) == 0 {
|
||||
missed = append(missed, name+"(无结果)")
|
||||
continue
|
||||
}
|
||||
if hits[0].Name == name {
|
||||
top1++
|
||||
} else {
|
||||
missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name))
|
||||
}
|
||||
for i, h := range hits {
|
||||
if h.Name == name {
|
||||
mrr += 1.0 / float64(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
n := float64(len(names))
|
||||
rate := 100 * float64(top1) / n
|
||||
fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names))
|
||||
fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n)
|
||||
if len(missed) > 0 {
|
||||
fmt.Printf(" 未命中 top-1(前 10):%v\n", firstN(missed, 10))
|
||||
}
|
||||
for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} {
|
||||
hits := st.Search(q, 2)
|
||||
got := []string{}
|
||||
for _, h := range hits {
|
||||
got = append(got, h.Name)
|
||||
}
|
||||
fmt.Printf(" 查询「%s」→ %v\n", q, got)
|
||||
}
|
||||
|
||||
if os.Getenv("KB_DIAG_SWEEP") != "" {
|
||||
fmt.Printf("\n === 融合权重扫描(1.0 = 只用稠密路,0.0 = 只用词法路)===\n")
|
||||
saved := densePathWeight
|
||||
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
|
||||
densePathWeight = w
|
||||
t1, m := 0, 0.0
|
||||
for _, name := range names {
|
||||
hits := st.Search(name, len(names))
|
||||
for i, h := range hits {
|
||||
if h.Name == name {
|
||||
if i == 0 {
|
||||
t1++
|
||||
}
|
||||
m += 1.0 / float64(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" 权重 %.1f:top-1 %2d/%d = %3.0f%% MRR %.3f\n",
|
||||
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
|
||||
}
|
||||
densePathWeight = saved
|
||||
}
|
||||
|
||||
if os.Getenv("KB_DIAG_ASSERT") != "" {
|
||||
if mrr/n < 0.34 {
|
||||
t.Fatalf("检索质量退化:MRR %.3f < 0.34(修复前 0.271,修复后 0.376)", mrr/n)
|
||||
}
|
||||
if rate < 18 {
|
||||
t.Fatalf("检索质量退化:top-1 %.0f%% < 18%%", rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func firstN(s []string, n int) []string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
176
internal/knowledge/search_fusion_test.go
Normal file
176
internal/knowledge/search_fusion_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。
|
||||
// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。
|
||||
type fakeDense struct {
|
||||
byText map[string]vector.Vector
|
||||
def vector.Vector
|
||||
}
|
||||
|
||||
func (f fakeDense) Vectorize(text string) vector.Vector {
|
||||
if v, ok := f.byText[text]; ok {
|
||||
return v
|
||||
}
|
||||
return f.def
|
||||
}
|
||||
|
||||
// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。
|
||||
func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) {
|
||||
return f.def, nil
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T, dense vector.Vectorizer) *Store {
|
||||
t.Helper()
|
||||
st := NewStore(t.TempDir())
|
||||
if dense != nil {
|
||||
st.SetVectorizer(dense)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func mustAdd(t *testing.T, st *Store, name, content string) {
|
||||
t.Helper()
|
||||
if err := st.Add(name, content); err != nil {
|
||||
t.Fatalf("add %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func names(hits []*Knowledge) []string {
|
||||
out := make([]string, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。
|
||||
func base36(n int) string {
|
||||
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
out := ""
|
||||
for n > 0 {
|
||||
out = string(digits[n%36]) + out
|
||||
n /= 36
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时,
|
||||
// 词法路必须把结果救回来——修复前这里直接返回空。
|
||||
func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) {
|
||||
dense := fakeDense{def: vector.Vector{"0": 1}}
|
||||
st := newTestStore(t, dense)
|
||||
mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明")
|
||||
mustAdd(t, st, "weather_doc", "天气预报 晴转多云")
|
||||
|
||||
// 让"更新"的稠密向量为空(模拟停用词化)
|
||||
st.SetVectorizer(fakeDense{
|
||||
byText: map[string]vector.Vector{"更新": {}},
|
||||
def: vector.Vector{"0": 1},
|
||||
})
|
||||
hits := st.Search("更新", 5)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)")
|
||||
}
|
||||
if hits[0].Name != "changelog_v1" {
|
||||
t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时,
|
||||
// 排序必须由词法路决定。
|
||||
func TestSearchLexicalBreaksDenseTies(t *testing.T) {
|
||||
same := vector.Vector{"0": 1, "1": 1}
|
||||
st := newTestStore(t, fakeDense{def: same})
|
||||
mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令")
|
||||
mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册")
|
||||
mustAdd(t, st, "privacy_policy", "隐私政策")
|
||||
|
||||
hits := st.Search("hmapdev 构建", 3)
|
||||
if len(hits) == 0 || hits[0].Name != "plugin_dev_build" {
|
||||
t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build,实际: %v", names(hits))
|
||||
}
|
||||
}
|
||||
|
||||
// 词法路的候选中选阈值必须是 0:TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05
|
||||
// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。
|
||||
//
|
||||
// 判据分两层,各钉一半:
|
||||
// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明
|
||||
// (同一候选在默认阈值下被过滤、阈值 0 时被召回);
|
||||
// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。
|
||||
// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词,
|
||||
// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27,
|
||||
// 前提断言直接把这两版夹具否掉了)。
|
||||
func TestLexicalStoreUsesZeroMinScore(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
if got := st.lex.MinScore(); got != 0 {
|
||||
t.Fatalf("词法路阈值必须为 0,实际 %v(沿用稠密路阈值会静默丢候选)", got)
|
||||
}
|
||||
if got := st.vec.MinScore(); got != vector.DefaultMinScore {
|
||||
t.Fatalf("稠密路阈值应保持默认 %v,实际 %v", vector.DefaultMinScore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中
|
||||
// (或新条目只在其中一路可见)。
|
||||
func TestAddRemoveKeepsBothPaths(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
mustAdd(t, st, "alpha", "alpha 独有词 alphaonly")
|
||||
mustAdd(t, st, "beta", "beta 独有词 betaonly")
|
||||
|
||||
has := func(q, want string) bool {
|
||||
for _, h := range st.Search(q, 5) {
|
||||
if h.Name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has("alphaonly", "alpha") {
|
||||
t.Fatal("新增条目应可被检索到")
|
||||
}
|
||||
if err := st.Remove("alpha"); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if has("alphaonly", "alpha") {
|
||||
t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)")
|
||||
}
|
||||
if !has("betaonly", "beta") {
|
||||
t.Fatal("删除其它条目不应影响 beta")
|
||||
}
|
||||
}
|
||||
|
||||
// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。
|
||||
func TestSearchDeterministicOnTies(t *testing.T) {
|
||||
same := vector.Vector{"0": 1}
|
||||
st := newTestStore(t, fakeDense{def: same})
|
||||
for _, n := range []string{"ccc", "aaa", "bbb"} {
|
||||
mustAdd(t, st, n, "完全一样的内容")
|
||||
}
|
||||
first := names(st.Search("完全一样的内容", 3))
|
||||
for i := 0; i < 5; i++ {
|
||||
got := names(st.Search("完全一样的内容", 3))
|
||||
for j := range first {
|
||||
if got[j] != first[j] {
|
||||
t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 两路都空时不能 panic,且应返回空。
|
||||
func TestSearchEmptyStore(t *testing.T) {
|
||||
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
|
||||
if hits := st.Search("随便", 5); len(hits) != 0 {
|
||||
t.Fatalf("空库应返回空,实际 %v", names(hits))
|
||||
}
|
||||
}
|
||||
@ -135,6 +135,14 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
doc.DenseFP = ds.Fingerprint()
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
// 迁移结果必须落盘。否则磁盘上的 DenseFP 永远对不上当前空间,
|
||||
// 判定条件永远成立:每次启动都重算同一批文档,磁盘状态永不收敛。
|
||||
// 迁移是一次性的昂贵操作(实测 200 篇约 7s),所以当场写盘,
|
||||
// 而不是只依赖关停时的 flush——被 kill -9 也不会白算。
|
||||
s.dirty = true
|
||||
s.flushLocked()
|
||||
}
|
||||
log.Printf("[document memory] dense index built: %d new vectors", count)
|
||||
}
|
||||
|
||||
@ -148,12 +156,17 @@ func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
return nil
|
||||
}
|
||||
fp := s.denseSpace.Fingerprint()
|
||||
dim := s.denseSpace.Dim()
|
||||
var parts [][]float64
|
||||
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 {
|
||||
parts = append(parts, tv)
|
||||
}
|
||||
for _, b := range doc.Blocks {
|
||||
if len(b.Vector) > 0 && b.Fingerprint == fp {
|
||||
// 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按
|
||||
// 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被
|
||||
// 标上当前指纹——于是该文档在检索侧被长度守卫永久跳过,
|
||||
// 且每次启动都会重算。维度不符的块一律不参与融合。
|
||||
if len(b.Vector) == dim && b.Fingerprint == fp {
|
||||
parts = append(parts, b.Vector)
|
||||
}
|
||||
}
|
||||
@ -513,7 +526,10 @@ func (s *Store) loadAll() error {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".json") || !strings.HasPrefix(e.Name(), "doc_") {
|
||||
// 只要求 .json:Insert 接受任意 ID 并落盘为 <id>.json,若这里再按
|
||||
// doc_ 前缀过滤,传自定义 ID 的文档重启后会静默消失。
|
||||
// 空 ID 仍会被下面的校验跳过。
|
||||
if !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
@ -534,6 +550,11 @@ func (s *Store) loadAll() error {
|
||||
func (s *Store) flush() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.flushLocked()
|
||||
}
|
||||
|
||||
// flushLocked 是 flush 的核心,调用方必须已持有 s.mu。
|
||||
func (s *Store) flushLocked() {
|
||||
if !s.dirty {
|
||||
return
|
||||
}
|
||||
|
||||
@ -467,3 +467,188 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
t.Error("summary should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ———— 回归:向量迁移的落盘与维度一致性 ————
|
||||
//
|
||||
// 以下四条来自 v1.2.0-beta.2 的压测(报告 /var/tmp/stress/REPORT.md):
|
||||
// 迁移结果不落盘(每次启动白算一遍)、块指纹对但维度错时污染文档向量、
|
||||
// 以及 Insert 与 loadAll 的 ID 约定不对称。
|
||||
|
||||
// fakeSpace 是可控的统一向量空间;calls 记录被真正要求算向量的次数,
|
||||
// 用来直接证明「已对齐的文档不再重算」——比读日志断言可靠。
|
||||
type fakeSpace struct {
|
||||
fp string
|
||||
dim int
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSpace) VectorizeDense(string) ([]float64, error) {
|
||||
f.calls++
|
||||
v := make([]float64, f.dim)
|
||||
for i := range v {
|
||||
v[i] = float64(i + 1)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (f *fakeSpace) EmbedImageDense([]byte, string) ([]float64, error) { return f.VectorizeDense("") }
|
||||
func (f *fakeSpace) Fingerprint() string { return f.fp }
|
||||
func (f *fakeSpace) Dim() int { return f.dim }
|
||||
func (f *fakeSpace) Loaded() bool { return true }
|
||||
func (f *fakeSpace) Close() {}
|
||||
|
||||
// 迁移结果必须落盘:迁移后换一个 Store 实例(模拟重启)读到的应是新空间向量,
|
||||
// 且再跑一次迁移不应重算任何文档。
|
||||
func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟上个向量空间留下的状态:维度与指纹都对不上
|
||||
stale := make([]float64, 999)
|
||||
for i := range stale {
|
||||
stale[i] = 0.01
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
|
||||
DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.SetDenseSpace(sp)
|
||||
s1.BuildDenseIndex(sp)
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移未在内存生效: %+v", got)
|
||||
}
|
||||
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
loaded := s2.Get("doc_persist")
|
||||
if loaded == nil {
|
||||
t.Fatal("重启后文档不见了")
|
||||
}
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
|
||||
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
s2.SetDenseSpace(sp2)
|
||||
s2.BuildDenseIndex(sp2)
|
||||
if sp2.calls != 0 {
|
||||
t.Fatalf("已对齐的文档被重算了 %d 次(期望 0)", sp2.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 块向量维度与当前空间不符时不得参与融合:否则 512 维文本 + 2048 维块
|
||||
// 会被 FuseVectors 按最大维度拼成 2048 维、并带上当前指纹,导致该文档在
|
||||
// 检索侧被长度守卫永久跳过且每次启动重算。
|
||||
func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sp := &fakeSpace{fp: "space-NEW-8", dim: 8}
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
bad := make([]float64, 2048)
|
||||
for i := range bad {
|
||||
bad[i] = 0.02
|
||||
}
|
||||
good := make([]float64, 8)
|
||||
for i := range good {
|
||||
good[i] = 0.5
|
||||
}
|
||||
doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
|
||||
Blocks: []memory.MemoryBlock{
|
||||
{ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略
|
||||
{ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合
|
||||
}}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetDenseSpace(sp)
|
||||
s.BuildDenseIndex(sp)
|
||||
|
||||
got := s.Get("doc_bad")
|
||||
if got == nil {
|
||||
t.Fatal("文档未加载")
|
||||
}
|
||||
if len(got.DenseVec) != 8 {
|
||||
t.Fatalf("坏块污染了文档向量:期望 %d 维,实际 %d 维(指纹 %q)",
|
||||
8, len(got.DenseVec), got.DenseFP)
|
||||
}
|
||||
// 同维度的正常块仍须参与融合:不应因为这次修复而整体失效
|
||||
textOnly := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
textVec, _ := textOnly.VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
if equalFloats(got.DenseVec, textVec) {
|
||||
t.Fatal("同维度的媒体块没有参与融合(修复过度)")
|
||||
}
|
||||
}
|
||||
|
||||
func equalFloats(a, b []float64) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Insert 接受任意 ID,loadAll 就必须把它读回来,否则自定义 ID 的文档
|
||||
// 重启后静默消失。
|
||||
func TestLoadAllLoadsCustomID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if s2.Get("my-notes") == nil {
|
||||
t.Fatal("自定义 ID 的文档重启后消失(Insert 与 loadAll 的 ID 约定不对称)")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 必须把内存态变更写盘(关停链上没有它时 flush 形同虚设)。
|
||||
func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s1.Insert(&Doc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 直接改内存并置脏,模拟「只在内存里发生的变更」
|
||||
s1.mu.Lock()
|
||||
s1.docs["doc_flush"].Summary = "关停落盘(已改)"
|
||||
s1.dirty = true
|
||||
s1.mu.Unlock()
|
||||
s1.Stop()
|
||||
|
||||
s2 := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Stop()
|
||||
if got := s2.Get("doc_flush"); got == nil || got.Summary != "关停落盘(已改)" {
|
||||
t.Fatalf("Stop 未落盘: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,6 +71,29 @@ func NewGraphDB(dbPath string) (*GraphDB, error) {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// OpenGraphDBReadOnly 以**受限句柄**打开图库:可读、可恢复 WAL,但**一切写入被拒**。
|
||||
//
|
||||
// 这是"子 agent 改不了主记忆"的**结构性**保证(设计 docs/zh/resident-subagent-design.md
|
||||
// §5):不是靠调用方自觉不写,而是把写入在 SQLite 这一层就关掉
|
||||
// (`PRAGMA query_only=1` —— 任何 INSERT/UPDATE/DELETE 都会直接报错)。
|
||||
//
|
||||
// 为什么不用 DSN 的 `mode=ro`:只读模式的连接在 WAL 库上无法自行恢复 -wal,
|
||||
// 而主库在父 agent 手里是持续写入的。query_only 让连接保持正常打开能力,
|
||||
// 同时**只堵写**,语义正是我们要的。
|
||||
//
|
||||
// 注意:不建表、不迁移 —— 受限句柄假定库已存在(由父 agent 建好)。
|
||||
func OpenGraphDBReadOnly(dbPath string) (*GraphDB, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open graph db (readonly): %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA query_only=1"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("set query_only: %w", err)
|
||||
}
|
||||
return &GraphDB{db: db, dbPath: dbPath}, nil
|
||||
}
|
||||
|
||||
func (g *GraphDB) initSchema() error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
@ -233,6 +256,9 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
|
||||
}
|
||||
|
||||
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
||||
// Path 返回本库的存储路径(父 agent 用它为驻留子打开**受限句柄**)。
|
||||
func (g *GraphDB) Path() string { return g.dbPath }
|
||||
|
||||
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
||||
return ec, rc, err
|
||||
@ -603,6 +629,46 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ExportTriples 导出库中的**活跃**三元组(供父 agent 在回收阶段收割子的 temp)。
|
||||
//
|
||||
// 设计 docs/zh/resident-subagent-design.md §9(回收 = 父读 temp → 选记录 → 合入 main)。
|
||||
// 只导出 `status='active'` 的关系,并把实体名一并带出(Relation 已含 SourceName/TargetName),
|
||||
// 于是合入侧可以直接复用 Commit —— 它按实体名 upsert、按
|
||||
// (source, relation, target) 幂等,因此"重复收割"不会造成重复条目。
|
||||
//
|
||||
// limit <= 0 表示不限量。
|
||||
func (g *GraphDB) ExportTriples(limit int) ([]Triple, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
q := `SELECT s.name, r.relation_type, t.name, r.confidence
|
||||
FROM relations r
|
||||
JOIN entities s ON s.id = r.source_id
|
||||
JOIN entities t ON t.id = r.target_id
|
||||
WHERE r.status = 'active'
|
||||
ORDER BY r.id`
|
||||
args := []interface{}{}
|
||||
if limit > 0 {
|
||||
q += " LIMIT ?"
|
||||
args = append(args, limit)
|
||||
}
|
||||
rows, err := g.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Triple
|
||||
for rows.Next() {
|
||||
var tr Triple
|
||||
if err := rows.Scan(&tr.Subject, &tr.Relation, &tr.Object, &tr.Confidence); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
81
internal/memory/graph_readonly_test.go
Normal file
81
internal/memory/graph_readonly_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package memory
|
||||
|
||||
// N2a 第一块砖:**受限句柄**(query_only)—— 子是"读得到主记忆、写不进去"的结构性保证。
|
||||
//
|
||||
// 设计:docs/zh/resident-subagent-design.md §5(两级空间)与 §5.5(子的记忆面)。
|
||||
// 轻量内核拿的是「主库受限句柄(只读)+ 自己的 temp 实例(读写)」,
|
||||
// 于是"子改不了 main"不靠调用方自觉,而是被 SQLite 直接拒。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenGraphDBReadOnly_ReadsWorkWritesRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "main.db")
|
||||
|
||||
// 父 agent 先建库并写入数据。
|
||||
main, err := NewGraphDB(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := main.Commit([]Triple{{
|
||||
Subject: "张三", Relation: "任职于", Object: "某公司",
|
||||
}}, "sess-1", 1); err != nil {
|
||||
t.Fatalf("父写入失败: %v", err)
|
||||
}
|
||||
|
||||
// 子拿到受限句柄。
|
||||
ro, err := OpenGraphDBReadOnly(path)
|
||||
if err != nil {
|
||||
t.Fatalf("打开受限句柄失败: %v", err)
|
||||
}
|
||||
defer ro.Close()
|
||||
|
||||
// ① 读得到。
|
||||
res, err := ro.Recall([]string{"张三"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("受限句柄应能读: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("读取结果不应为 nil")
|
||||
}
|
||||
|
||||
// ② 写不进去 —— 结构性拒绝(不是"约定不写")。
|
||||
if _, _, err := ro.Commit([]Triple{{
|
||||
Subject: "李四", Relation: "任职于", Object: "另一公司",
|
||||
}}, "sess-2", 1); err == nil {
|
||||
t.Fatal("受限句柄的写入必须被 SQLite 拒绝(query_only)")
|
||||
}
|
||||
if _, err := ro.Purge(map[string]string{"subject": "张三"}, "soft"); err == nil {
|
||||
t.Fatal("受限句柄的 Purge 必须被拒")
|
||||
}
|
||||
|
||||
// ③ 父库不受影响:子写失败没留下任何痕迹。
|
||||
again, err := main.Recall([]string{"李四"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != nil && len(again.Entities) > 0 {
|
||||
t.Fatalf("子写失败却在主库留下了痕迹:%+v", again.Entities)
|
||||
}
|
||||
}
|
||||
|
||||
// 受限句柄不建表、不迁移:库不存在时按只读语义处理(查不到东西),
|
||||
// 但**不得**悄悄创建出一个空库(否则"子"会凭空造出主库)。
|
||||
func TestOpenGraphDBReadOnly_DoesNotCreateSchema(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.db")
|
||||
|
||||
ro, err := OpenGraphDBReadOnly(path)
|
||||
if err != nil {
|
||||
// 打开本身允许失败(某些平台会因文件不存在直接报错)——
|
||||
// 关键是"不得建表成功"。
|
||||
return
|
||||
}
|
||||
defer ro.Close()
|
||||
if _, err := ro.Recall([]string{"任意"}, nil, 1, ""); err == nil {
|
||||
t.Fatal("库不存在时受限句柄的查询应当报错,而不是凭空建表后返回空结果")
|
||||
}
|
||||
}
|
||||
191
internal/memory/light_memory.go
Normal file
191
internal/memory/light_memory.go
Normal file
@ -0,0 +1,191 @@
|
||||
package memory
|
||||
|
||||
// 轻量内核的**图记忆装配**(设计 §5.6):
|
||||
//
|
||||
// temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死
|
||||
// 主库**受限句柄**(只读) ← 子只能读;写入被 SQLite 结构性拒绝
|
||||
//
|
||||
// 子的查询 = 两个实例各查一次,**应用层合并**(并集):
|
||||
// - 实体:按名字去重(同名视为同一实体,保留 mention_count 较大的一条)
|
||||
// - 关系:按 (源名, 关系, 目标名) 去重
|
||||
//
|
||||
// 为什么合并放在应用层而不是给共享记忆层加 space 列:**独立存储实例**让隔离成为
|
||||
// 结构性的(不同库),不依赖 where 条件;代价就是这个合并函数。
|
||||
//
|
||||
// 回收(N2d)由父 agent 读 `Temp()` 并选出要保留的记录写进主库。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LightMemory 是驻留子的图记忆装配。
|
||||
type LightMemory struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// temp 是子自己的图记忆实例(独立存储)。当不允许子写图记忆时为 nil。
|
||||
temp *GraphDB
|
||||
// main 是主图记忆的受限句柄(只读)。
|
||||
main *GraphDB
|
||||
|
||||
// allowWrite 报告是否允许写 temp(false ⇒ 子对图记忆完全只读)。
|
||||
allowWrite bool
|
||||
}
|
||||
|
||||
// NewLightMemory 构造子的图记忆装配。
|
||||
//
|
||||
// - main: 主库句柄。为 nil 时表示"只能用自己的 temp"(一般不该发生)。
|
||||
// - tempPath: 子 temp 实例的存储路径(独立文件)。allowWrite=false 时**不会**打开它。
|
||||
// - allowWrite: 是否允许子写图记忆(用户给的备选开关,默认建议 true)。
|
||||
func NewLightMemory(main *GraphDB, tempPath string, allowWrite bool) (*LightMemory, error) {
|
||||
m := &LightMemory{main: main, allowWrite: allowWrite}
|
||||
if !allowWrite {
|
||||
return m, nil
|
||||
}
|
||||
if tempPath == "" {
|
||||
return nil, fmt.Errorf("允许写图记忆时必须给出 temp 存储路径")
|
||||
}
|
||||
temp, err := NewGraphDB(tempPath) // temp 是子自己的库:建表/迁移都正常
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open temp graph db: %w", err)
|
||||
}
|
||||
m.temp = temp
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// AllowWrite 报告子能否写图记忆。
|
||||
func (m *LightMemory) AllowWrite() bool { return m.allowWrite }
|
||||
|
||||
// Temp 返回子的 temp 实例(可能为 nil)。回收时父 agent 用它读取/收割。
|
||||
func (m *LightMemory) Temp() *GraphDB {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.temp
|
||||
}
|
||||
|
||||
// Main 返回主库的受限句柄。
|
||||
func (m *LightMemory) Main() *GraphDB { return m.main }
|
||||
|
||||
// Commit 写入图记忆:**只落 temp**(设计 §5:写目标收窄到自己的空间)。
|
||||
func (m *LightMemory) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||
if !m.allowWrite {
|
||||
return 0, 0, fmt.Errorf("本 agent 不允许写图记忆(allowTempGraphWrite=false)")
|
||||
}
|
||||
m.mu.RLock()
|
||||
temp := m.temp
|
||||
m.mu.RUnlock()
|
||||
if temp == nil {
|
||||
return 0, 0, fmt.Errorf("temp 图记忆实例不可用")
|
||||
}
|
||||
return temp.Commit(triples, sessionID, turnID)
|
||||
}
|
||||
|
||||
// Recall 在 temp ∪ main 上做召回(并集),按上述规则合并去重。
|
||||
//
|
||||
// 任一侧出错都不影响另一侧的结果:单侧失败只在两侧都失败时返回错误
|
||||
// (主库是只读句柄,任何"查询即失败"都说明是真实故障)。
|
||||
func (m *LightMemory) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) {
|
||||
m.mu.RLock()
|
||||
temp := m.temp
|
||||
m.mu.RUnlock()
|
||||
|
||||
var (
|
||||
parts []*RecallResult
|
||||
lastErr error
|
||||
okAny bool
|
||||
)
|
||||
if temp != nil {
|
||||
r, err := temp.Recall(keywords, seedEntities, depth, sessionFilter)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
okAny = true
|
||||
parts = append(parts, r)
|
||||
}
|
||||
}
|
||||
if m.main != nil {
|
||||
r, err := m.main.Recall(keywords, seedEntities, depth, sessionFilter)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
okAny = true
|
||||
parts = append(parts, r)
|
||||
}
|
||||
}
|
||||
if !okAny {
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("没有可用的图记忆实例")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
return mergeRecall(parts...), nil
|
||||
}
|
||||
|
||||
// mergeRecall 把多个来源的召回结果并成一份(实体按名字、关系按三元组去重)。
|
||||
//
|
||||
// 顺序确定(先 entities/relations 各自排序),便于测试与展示稳定。
|
||||
func mergeRecall(parts ...*RecallResult) *RecallResult {
|
||||
out := &RecallResult{}
|
||||
seenEntity := map[string]int{} // 小写名 → out.Entities 下标
|
||||
for _, p := range parts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range p.Entities {
|
||||
k := strings.ToLower(strings.TrimSpace(e.Name))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if i, dup := seenEntity[k]; dup {
|
||||
// 同名实体:保留 mention_count 较大的一条(更新的那个)。
|
||||
if e.MentionCount > out.Entities[i].MentionCount {
|
||||
out.Entities[i] = e
|
||||
}
|
||||
continue
|
||||
}
|
||||
seenEntity[k] = len(out.Entities)
|
||||
out.Entities = append(out.Entities, e)
|
||||
}
|
||||
}
|
||||
seenRel := map[string]struct{}{}
|
||||
for _, p := range parts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range p.Relations {
|
||||
k := strings.ToLower(strings.TrimSpace(r.SourceName)) + "\x00" +
|
||||
strings.ToLower(strings.TrimSpace(r.RelationType)) + "\x00" +
|
||||
strings.ToLower(strings.TrimSpace(r.TargetName))
|
||||
if _, dup := seenRel[k]; dup {
|
||||
continue
|
||||
}
|
||||
seenRel[k] = struct{}{}
|
||||
out.Relations = append(out.Relations, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Entities, func(i, j int) bool { return out.Entities[i].Name < out.Entities[j].Name })
|
||||
sort.Slice(out.Relations, func(i, j int) bool {
|
||||
if out.Relations[i].SourceName != out.Relations[j].SourceName {
|
||||
return out.Relations[i].SourceName < out.Relations[j].SourceName
|
||||
}
|
||||
if out.Relations[i].RelationType != out.Relations[j].RelationType {
|
||||
return out.Relations[i].RelationType < out.Relations[j].RelationType
|
||||
}
|
||||
return out.Relations[i].TargetName < out.Relations[j].TargetName
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Close 关闭 temp 实例(主库句柄由父 agent 拥有,不在这里关)。
|
||||
func (m *LightMemory) Close() error {
|
||||
m.mu.Lock()
|
||||
temp := m.temp
|
||||
m.temp = nil
|
||||
m.mu.Unlock()
|
||||
if temp != nil {
|
||||
return temp.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
208
internal/memory/light_memory_test.go
Normal file
208
internal/memory/light_memory_test.go
Normal file
@ -0,0 +1,208 @@
|
||||
package memory
|
||||
|
||||
// N2a 第二块砖:轻量内核的图记忆装配(temp 可写 + 主库只读 + 并集查询)。
|
||||
//
|
||||
// 设计:docs/zh/resident-subagent-design.md §5.6。
|
||||
// 关键性质:
|
||||
// ① 子写入只落 temp(写不进展主库)—— main 是受限句柄,结构性拒绝;
|
||||
// ② 子查询 = temp ∪ main;
|
||||
// ③ 两个子之间 temp 互不可见;
|
||||
// ④ allowTempGraphWrite=false 时子对图记忆完全只读(没有 temp 实例)。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newMainWith 建一个主库并写入若干三元组。
|
||||
func newMainWith(t *testing.T, dir string, triples ...Triple) *GraphDB {
|
||||
t.Helper()
|
||||
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(triples) > 0 {
|
||||
if _, _, err := main.Commit(triples, "sess", 1); err != nil {
|
||||
t.Fatalf("主库写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
return main
|
||||
}
|
||||
|
||||
func names(entities []Entity) []string {
|
||||
out := make([]string, 0, len(entities))
|
||||
for _, e := range entities {
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasName(entities []Entity, name string) bool {
|
||||
for _, e := range entities {
|
||||
if e.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 子写入只落 temp;查询是 temp ∪ main。
|
||||
func TestLightMemory_WriteGoesToTempReadIsUnion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||
defer main.Close()
|
||||
|
||||
light, err := NewLightMemory(main, filepath.Join(dir, "sub-1.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer light.Close()
|
||||
|
||||
// 子写自己的发现。
|
||||
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "任职于", Object: "乙公司"}}, "sess", 1); err != nil {
|
||||
t.Fatalf("子写入 temp 应成功: %v", err)
|
||||
}
|
||||
|
||||
// 查询能同时看到 main(张三)与 temp(李四)。
|
||||
res, err := light.Recall([]string{"张三", "李四"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasName(res.Entities, "张三") {
|
||||
t.Fatalf("子应看得到主库内容,实际 %v", names(res.Entities))
|
||||
}
|
||||
if !hasName(res.Entities, "李四") {
|
||||
t.Fatalf("子应看得到自己 temp 的内容,实际 %v", names(res.Entities))
|
||||
}
|
||||
|
||||
// 主库**没有**被写入(子改不了 main)。
|
||||
mainOnly, err := main.Recall([]string{"李四"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasName(mainOnly.Entities, "李四") {
|
||||
t.Fatalf("主的图记忆里不该出现子才知道的实体:%v", names(mainOnly.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// 两个子之间 temp 互不可见(只有 main 共享)。
|
||||
func TestLightMemory_ChildrenTempsAreIsolated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "共享", Relation: "属于", Object: "主库"})
|
||||
defer main.Close()
|
||||
|
||||
a, err := NewLightMemory(main, filepath.Join(dir, "sub-a.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := NewLightMemory(main, filepath.Join(dir, "sub-b.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
if _, _, err := a.Commit([]Triple{{Subject: "A的秘密", Relation: "仅属于", Object: "A"}}, "s", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resB, err := b.Recall([]string{"A的秘密", "共享"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasName(resB.Entities, "A的秘密") {
|
||||
t.Fatalf("B 不该看到 A 的 temp:%v", names(resB.Entities))
|
||||
}
|
||||
if !hasName(resB.Entities, "共享") {
|
||||
t.Fatalf("B 应看得到共享的 main:%v", names(resB.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// allowTempGraphWrite=false:子对图记忆完全只读(没有 temp 实例,写入被拒)。
|
||||
func TestLightMemory_WriteDisabledIsFullyReadOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||
defer main.Close()
|
||||
|
||||
light, err := NewLightMemory(main, "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("不允许写时不应要求 temp 路径: %v", err)
|
||||
}
|
||||
defer light.Close()
|
||||
|
||||
if light.AllowWrite() {
|
||||
t.Fatal("AllowWrite 应为 false")
|
||||
}
|
||||
if light.Temp() != nil {
|
||||
t.Fatal("不允许写时不该有 temp 实例")
|
||||
}
|
||||
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "x", Object: "y"}}, "s", 1); err == nil {
|
||||
t.Fatal("不允许写时的写入必须被拒")
|
||||
}
|
||||
// 读仍然可用(只读主库)。
|
||||
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasName(res.Entities, "张三") {
|
||||
t.Fatalf("只读模式仍应读到主库:%v", names(res.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// 并集必须去重:同一个实体同时存在于 main 与 temp 时只出现一次。
|
||||
func TestLightMemory_UnionDedupesSameEntity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
|
||||
defer main.Close()
|
||||
|
||||
light, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer light.Close()
|
||||
if _, _, err := light.Commit([]Triple{{Subject: "张三", Relation: "擅长", Object: "Go"}}, "s", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := light.Recall([]string{"张三"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, e := range res.Entities {
|
||||
if e.Name == "张三" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("同名实体在并集中应只出现一次,实际 %d 次:%v", n, names(res.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// 并集查询的确定性(同样的输入必得同样的顺序),便于断言与展示稳定。
|
||||
func TestMergeRecall_Deterministic(t *testing.T) {
|
||||
a := &RecallResult{
|
||||
Entities: []Entity{{Name: "b", MentionCount: 1}, {Name: "a", MentionCount: 1}},
|
||||
Relations: []Relation{
|
||||
{SourceName: "z", RelationType: "r", TargetName: "y"},
|
||||
{SourceName: "a", RelationType: "r", TargetName: "b"},
|
||||
},
|
||||
}
|
||||
b := &RecallResult{
|
||||
Entities: []Entity{{Name: "a", MentionCount: 5}},
|
||||
Relations: []Relation{{SourceName: "a", RelationType: "r", TargetName: "b"}},
|
||||
}
|
||||
got := mergeRecall(a, b)
|
||||
if len(got.Entities) != 2 || got.Entities[0].Name != "a" || got.Entities[1].Name != "b" {
|
||||
t.Fatalf("实体合并结果=%v", names(got.Entities))
|
||||
}
|
||||
if got.Entities[0].MentionCount != 5 {
|
||||
t.Fatalf("同名实体应保留 mention_count 较大者,实际 %d", got.Entities[0].MentionCount)
|
||||
}
|
||||
if len(got.Relations) != 2 {
|
||||
t.Fatalf("关系应去重后剩 2 条,实际 %d", len(got.Relations))
|
||||
}
|
||||
if got.Relations[0].SourceName != "a" {
|
||||
t.Fatalf("关系未按源名排序:%+v", got.Relations)
|
||||
}
|
||||
}
|
||||
117
internal/memory/reclaim_test.go
Normal file
117
internal/memory/reclaim_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package memory
|
||||
|
||||
// N2d 数据面:**回收**=父读子的 temp → 选记录 → 合入 main。
|
||||
//
|
||||
// 设计 §9:回收是"取消语义"(收割后取消该驻留子),且**由父决定纳入哪些**。
|
||||
// 这里只做数据面(读得到、合得进、幂等),控制面(谁来决定、何时取消)在 N3/N6。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportTriples_OnlyActiveWithNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
g, err := NewGraphDB(filepath.Join(dir, "g.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
if _, _, err := g.Commit([]Triple{
|
||||
{Subject: "张三", Relation: "任职于", Object: "甲公司"},
|
||||
{Subject: "李四", Relation: "合作", Object: "王五"},
|
||||
}, "sess", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := g.ExportTriples(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("导出 %d 条,期望 2:%+v", len(got), got)
|
||||
}
|
||||
// 实体名必须带出来(合入侧要靠名字 upsert,而不是内部 id)。
|
||||
seen := map[string]bool{}
|
||||
for _, tr := range got {
|
||||
if tr.Subject == "" || tr.Object == "" || tr.Relation == "" {
|
||||
t.Fatalf("导出的三元组字段不全:%+v", tr)
|
||||
}
|
||||
seen[tr.Subject+"→"+tr.Object] = true
|
||||
}
|
||||
if !seen["张三→甲公司"] || !seen["李四→王五"] {
|
||||
t.Fatalf("导出内容不对:%+v", got)
|
||||
}
|
||||
|
||||
// limit 生效。
|
||||
one, err := g.ExportTriples(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(one) != 1 {
|
||||
t.Fatalf("limit=1 应导出 1 条,实际 %d", len(one))
|
||||
}
|
||||
}
|
||||
|
||||
// 回收主流程:子写 temp → 父导出 → 选中的合入 main;主库拿到、temp 不变。
|
||||
func TestReclaim_HarvestChildTempIntoMain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer main.Close()
|
||||
|
||||
child, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer child.Close()
|
||||
|
||||
// 子在 temp 里积累了两条发现。
|
||||
if _, _, err := child.Commit([]Triple{
|
||||
{Subject: "子发现A", Relation: "指向", Object: "结论1"},
|
||||
{Subject: "子发现B", Relation: "指向", Object: "结论2"},
|
||||
}, "sess", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 父:导出 → **选一条**("哪些纳入记忆"由父决定)→ 写进 main。
|
||||
all, err := child.Temp().ExportTriples(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("导出 %d 条,期望 2", len(all))
|
||||
}
|
||||
selected := []Triple{all[0]}
|
||||
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
|
||||
t.Fatalf("合入 main 失败: %v", err)
|
||||
}
|
||||
|
||||
// 主库只拿到选中的那条。
|
||||
mainRes, err := main.Recall([]string{"子发现A", "子发现B"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasName(mainRes.Entities, "子发现A") {
|
||||
t.Fatalf("选中的记录应进主库:%v", names(mainRes.Entities))
|
||||
}
|
||||
if hasName(mainRes.Entities, "子发现B") {
|
||||
t.Fatalf("未选中的记录不该进主库:%v", names(mainRes.Entities))
|
||||
}
|
||||
|
||||
// 重复收割是幂等的(Commit 按实体名 upsert + 关系唯一约束)。
|
||||
before := len(mainRes.Entities)
|
||||
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := main.Recall([]string{"子发现A"}, nil, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after.Entities) != before {
|
||||
t.Fatalf("重复收割不应产生重复实体:before=%d after=%d", before, len(after.Entities))
|
||||
}
|
||||
}
|
||||
@ -65,6 +65,18 @@ func (a *ProviderAdapter) embed(input embedding.Input) ([]float64, error) {
|
||||
func (a *ProviderAdapter) Fingerprint() string { return a.info.Fingerprint }
|
||||
func (a *ProviderAdapter) Dim() int { return a.info.Dimension }
|
||||
|
||||
// Modalities 报告该空间支持的输入模态(text/image/...)。
|
||||
//
|
||||
// 模态是**可选能力**:MultimodalEmbedder 契约里没有它,状态查询按接口断言取用,
|
||||
// 所以这里既不改公开接口,也不影响其它实现(核心也不硬编码任何模型名)。
|
||||
func (a *ProviderAdapter) Modalities() []string {
|
||||
out := make([]string, 0, len(a.info.Modalities))
|
||||
for _, m := range a.info.Modalities {
|
||||
out = append(out, string(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *ProviderAdapter) Loaded() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
@ -77,6 +77,12 @@ type Store struct {
|
||||
docs []DocVector
|
||||
dim int
|
||||
index *InvertedIndex
|
||||
|
||||
// minScore 是候选分数下限。**必须按向量空间标定**:
|
||||
// 词向量/多模态余弦通常在 0.3~0.9,而 TF-IDF 余弦只有 0.0~0.2 ——
|
||||
// 用同一个阈值会把词法路的大量有效候选静默砍掉
|
||||
// (实测:知识库自检索 MRR 0.307 → 0.193 就是这么掉的)。
|
||||
minScore float64
|
||||
}
|
||||
|
||||
type DocVector struct {
|
||||
@ -86,9 +92,27 @@ type DocVector struct {
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
// DefaultMinScore 是默认候选中选阈值(沿用历史行为)。
|
||||
const DefaultMinScore = 0.05
|
||||
|
||||
// MinScore 返回当前候选中选阈值(供接线处自证用的是哪个阈值)。
|
||||
func (s *Store) MinScore() float64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.minScore
|
||||
}
|
||||
|
||||
// SetMinScore 调整候选中选阈值(按向量空间标定,见 minScore 字段注释)。
|
||||
func (s *Store) SetMinScore(v float64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.minScore = v
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
index: NewInvertedIndex(),
|
||||
index: NewInvertedIndex(),
|
||||
minScore: DefaultMinScore,
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,7 +184,7 @@ func (s *Store) SearchScored(query Vector, topK int) []DocVectorHit {
|
||||
for _, d := range s.docs {
|
||||
if d.ID == id {
|
||||
score := CosineSimilarity(query, d.Vector)
|
||||
if score > 0.05 {
|
||||
if score > s.minScore {
|
||||
results = append(results, scored{d, score})
|
||||
}
|
||||
break
|
||||
|
||||
@ -210,3 +210,29 @@ func BenchmarkExtractNGrams(b *testing.B) {
|
||||
extractNGrams(text, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 候选中选阈值必须**按向量空间标定**:词向量/多模态余弦通常在 0.3~0.9,
|
||||
// 而 TF-IDF 余弦只有 0.0~0.2。用同一个阈值会把词法路的有效候选静默砍掉
|
||||
// (知识库自检索 MRR 0.307→0.193 就是这么掉的,且当时看不出任何报错)。
|
||||
func TestSearchScoredRespectsMinScore(t *testing.T) {
|
||||
// 构造一个低余弦候选:共享特征 "a",但两个向量几乎正交 → cosine ≈ 0.02
|
||||
st := NewStore()
|
||||
st.Insert("doc", "", Vector{"a": 1, "b": 1}, nil) // |doc| = √2
|
||||
query := Vector{"a": 0.02, "c": 100} // 与 doc 的点积 0.02
|
||||
|
||||
hits := st.SearchScored(query, 10)
|
||||
for _, h := range hits {
|
||||
if h.Score < DefaultMinScore {
|
||||
t.Fatalf("默认阈值 %.2f 不该返回 %.5f 的候选", DefaultMinScore, h.Score)
|
||||
}
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("该查询在默认阈值下应被过滤,实际返回 %d 条", len(hits))
|
||||
}
|
||||
|
||||
st.SetMinScore(0)
|
||||
hits = st.SearchScored(query, 10)
|
||||
if len(hits) != 1 || hits[0].Doc.ID != "doc" {
|
||||
t.Fatalf("阈值设为 0 后应召回低余弦候选,实际 %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ var (
|
||||
//
|
||||
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
|
||||
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
|
||||
// 用新版 plugindev 重编),故跃到主版本号。
|
||||
// 用新版 hmapdev 重编),故跃到主版本号。
|
||||
// 1.1.0:记忆系统支持二进制多媒体节点——CAS 媒体存储 + L0/L2/L3 贯通。
|
||||
// 1.1.1:多模态贯通**插件边界**。内核实现公开 SDK 1.1.0 新增的媒体接口
|
||||
// (doc.insertWithMedia、io.injectMedia / injectMediaSync /
|
||||
@ -21,10 +21,14 @@ var (
|
||||
// 新增的注入行为标志位(InjectOptions:no_memory / context_policy)
|
||||
// 与 ChannelDef.ContextPolicy,使输入/排队注入/中断注入/同步注入都能
|
||||
// 声明「是否记入记忆」与「是否据此裁剪上下文」(默认都是否)。
|
||||
// 1.3.0:注入优先级贯通(SDK 1.3.0 的 InjectOptions.Priority 与
|
||||
// PriorityL1-L4)——插件可声明自己中断的级别,内核按四级中断阶梯调度;
|
||||
// 并实现 UnregisterOutputChannel,使动态通道(远程设备 device/<id>)
|
||||
// 能随资源生灭,而不是留下死通道骗模型。
|
||||
//
|
||||
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
||||
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
||||
Version = "1.2.0"
|
||||
Version = "1.3.0"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
@ -42,7 +46,10 @@ var (
|
||||
// 注入变体、InjectOptions、ChannelDef.ContextPolicy),因此声明为
|
||||
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
|
||||
// **插件调用、内核实现**,不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.2.0"
|
||||
// 1.3.0:内核实现了 SDK 1.3.0 的全部新增面 —— InjectOptions.Priority 的
|
||||
// 分级中断调度、PriorityL1-L4 常量的语义,以及 UnregisterOutputChannel
|
||||
// 在通道登记表 + io 设备表上的注销。同样:不调新面就不受影响。
|
||||
SDKCompatibleVersion = "1.3.0"
|
||||
|
||||
// SourceURL 是本内核构建所对应的源码地址。
|
||||
//
|
||||
|
||||
@ -17,7 +17,7 @@ const (
|
||||
//
|
||||
// 保留这张表只为**给出明确错误**:插件目录里躺着 plugin.so 而内核不再认它时,
|
||||
// 静默跳过会让「目录在但插件没加载」看起来像配置问题,而实际原因是需要用
|
||||
// 新版 plugindev 重编。
|
||||
// 新版 hmapdev 重编。
|
||||
var legacyCABIEntries = []string{"plugin.so", "plugin.dll", "plugin.dylib"}
|
||||
|
||||
// entryKind 描述插件入口归属的加载通道。
|
||||
|
||||
@ -115,7 +115,7 @@ func TestHasLegacyCABIEntry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 旧 .so 插件必须报「用新 plugindev 重编」而非静默跳过。
|
||||
// 旧 .so 插件必须报「用新 hmapdev 重编」而非静默跳过。
|
||||
func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.closeProcHost()
|
||||
@ -129,7 +129,7 @@ func TestTryDynamic_LegacyCABIGivesActionableError(t *testing.T) {
|
||||
}
|
||||
// 错误消息须指向解决办法,且明确业务代码无需改
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"plugindev", "plugin.bin", "业务代码"} {
|
||||
for _, want := range []string{"hmapdev", "plugin.bin", "业务代码"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("错误消息应含 %q,实际: %v", want, err)
|
||||
}
|
||||
|
||||
@ -181,7 +181,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectInterrupt:
|
||||
var p injectParams
|
||||
@ -191,7 +191,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectTextNoMem:
|
||||
var p injectParams
|
||||
@ -202,7 +202,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
return nil, err
|
||||
}
|
||||
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName))
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
case MethodIOInjectSync:
|
||||
var p injectParams
|
||||
@ -212,7 +212,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectMedia:
|
||||
@ -227,7 +227,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
|
||||
case MethodIOInjectMediaSync:
|
||||
@ -242,7 +242,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectInterruptMedia:
|
||||
@ -257,7 +257,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
|
||||
return nil, nil
|
||||
|
||||
// ---- 生命周期(原 case 8)----
|
||||
@ -695,6 +695,8 @@ type injectParams struct {
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
// Priority 声明中断注入的优先级(L1..L3);L4 内核独占,见 InjectOptions。
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// injectMediaParams 是带媒体注入/工具块注入的参数。
|
||||
@ -715,14 +717,35 @@ type injectMediaParams struct {
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// pubSdkInjectOpts 把 RPC 报文里的三个字段转成公开 SDK 的 InjectOptions。
|
||||
//
|
||||
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
|
||||
// 与旧三参数注入等价。
|
||||
func pubSdkInjectOpts(noMemory bool, policy, cleanerName string) pubsdk.InjectOptions {
|
||||
return pubsdk.InjectOptions{NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName}
|
||||
func pubSdkInjectOpts(noMemory bool, policy, cleanerName, priority string) pubsdk.InjectOptions {
|
||||
return pubsdk.InjectOptions{
|
||||
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName,
|
||||
Priority: clampExternalPriority(priority),
|
||||
}
|
||||
}
|
||||
|
||||
// clampExternalPriority 把外部插件声明的优先级夹到 L1..L3。
|
||||
//
|
||||
// 走本桥的必然是外部插件(独立进程/动态库),它们**不是内核级插件**,
|
||||
// 因此不能声明 L4——“立即打断”那类能力只属于编译期内置插件(如 WebUI 终止按钮)。
|
||||
//
|
||||
// 为什么在这里夹而不是只在内核里按 source 判:source 是插件自报的字段,
|
||||
// 外部插件可以冒用 "webui" 之名;而本函数所在的位置能确知“这来自外部进程”。
|
||||
// 内核侧的 isKernelLevelSource 是第二道闸(纵深防御)。
|
||||
func clampExternalPriority(priority string) string {
|
||||
switch priority {
|
||||
case "L4", "l4":
|
||||
return pubsdk.PriorityL3
|
||||
default:
|
||||
return priority
|
||||
}
|
||||
}
|
||||
|
||||
// validateContextPolicy 校验上下文策略取值,与 tool.register 同一套规则。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user