From 35508eb61497c288717154fdee426fc4f0e59d6c Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:55:33 +0800 Subject: [PATCH] feat(pi): add the Pi agent runtime with native workflow and retrieval engines Introduce an isolated Node worker that hosts the Pi SDK while Go keeps business state, permissions, MCP execution, evidence and delivery checks. The worker is published per source fingerprint, streams events back to Go and supports tool callbacks, cancellation, session resume and per-model transport settings. Agent dispatch, tool search, plan/task tools and migration controls are wired on top of it: large tool catalogs load through Pi tool_search, the conversation task board keeps its existing tools, and legacy mode/route values still resolve. Workflow gains a native DAG with versioned JSON checkpoints, and knowledge retrieval moves to native documents, splitter, HTTP embeddings, vector search and reranking. --- .github/workflows/pi-runtime.yml | 27 + CHANGELOG.md | 3 + cmd/build-runtime/main.go | 24 + config.example.yaml | 7 + docs/en-US/README.md | 2 + docs/en-US/pi-runtime.md | 26 + docs/en-US/release-process.md | 1 + docs/zh-CN/README.md | 2 + docs/zh-CN/architecture.md | 10 +- docs/zh-CN/pi-runtime.md | 60 + docs/zh-CN/release-process.md | 7 +- go.mod | 7 +- go.sum | 20 - internal/agentfinalizer/decision.go | 6 + .../agentfinalizer/runtime_status_test.go | 16 + internal/agentruntime/build.go | 140 ++ internal/agentruntime/build_test.go | 49 + internal/agentruntime/pi.go | 309 ++++ internal/agentruntime/pi_test.go | 122 ++ internal/agentruntime/session_lock.go | 48 + internal/agentruntime/types.go | 83 + internal/app/app.go | 3 + internal/config/agent_runtime.go | 25 + internal/config/agent_runtime_test.go | 15 + internal/config/config.go | 63 +- internal/handler/agent.go | 4 +- internal/handler/agent_multi_json_runner.go | 2 +- internal/handler/agent_single_json_runner.go | 2 +- internal/handler/batch_queue_executor.go | 4 +- internal/handler/config.go | 126 +- internal/handler/eino_single_agent.go | 2 +- internal/handler/multi_agent.go | 2 +- internal/knowledge/chunk_eino.go | 67 - internal/knowledge/document.go | 63 + internal/knowledge/eino_retrieve_chain.go | 24 - internal/knowledge/embedder.go | 53 +- internal/knowledge/embedding_http.go | 57 + internal/knowledge/index_pipeline.go | 99 +- internal/knowledge/indexer.go | 58 +- .../knowledge/{eino_meta.go => metadata.go} | 4 +- .../{eino_meta_test.go => metadata_test.go} | 0 internal/knowledge/native_pipeline_test.go | 60 + internal/knowledge/rerank_http.go | 5 +- internal/knowledge/rerank_http_test.go | 6 +- ...ine_retriever.go => retrieval_pipeline.go} | 24 +- internal/knowledge/retrieval_postprocess.go | 17 +- .../knowledge/retrieval_postprocess_test.go | 12 +- internal/knowledge/retrieve_chain.go | 21 + ...e_chain_test.go => retrieve_chain_test.go} | 0 internal/knowledge/retriever.go | 34 +- internal/knowledge/splitter.go | 123 ++ ...no_sqlite_indexer.go => sqlite_indexer.go} | 33 +- internal/knowledge/tool.go | 2 +- ...triever_adapter.go => vector_retriever.go} | 52 +- internal/knowledge/wire_retriever.go | 159 +- internal/multiagent/pi_integration_test.go | 164 ++ internal/multiagent/pi_local_tools.go | 342 ++++ internal/multiagent/pi_model.go | 63 + internal/multiagent/pi_plan_tools.go | 211 +++ internal/multiagent/pi_plan_tools_test.go | 55 + internal/multiagent/pi_runner.go | 463 +++++ internal/multiagent/runner.go | 24 +- internal/multiagent/runtime_dispatch.go | 55 + internal/openai/claude_native.go | 376 ++-- internal/update/build.go | 6 + internal/vision/client.go | 91 +- internal/workflow/agent_subgraph.go | 48 - internal/workflow/eino_branch.go | 107 -- internal/workflow/eino_callbacks.go | 22 - internal/workflow/eino_compile.go | 243 --- internal/workflow/engine.go | 111 +- internal/workflow/native_runtime.go | 149 ++ internal/workflow/nodes.go | 4 +- internal/workflow/resume.go | 116 ++ internal/workflow/runner.go | 12 +- .../workflow/{eino_runtime.go => runtime.go} | 0 .../{eino_compile_test.go => runtime_test.go} | 102 +- internal/workflow/state.go | 13 - run.sh | 2 + runtime/pi/.gitignore | 3 + runtime/pi/THIRD_PARTY_NOTICES.md | 3 + runtime/pi/package-lock.json | 1568 +++++++++++++++++ runtime/pi/package.json | 20 + runtime/pi/src/protocol.ts | 36 + runtime/pi/src/worker.ts | 318 ++++ runtime/pi/test/worker.test.mjs | 168 ++ runtime/pi/tsconfig.json | 15 + scripts/build-pi-runtime.sh | 5 + web/static/i18n/en-US.json | 4 +- web/static/i18n/zh-CN.json | 4 +- web/static/js/chat-plan-progress.js | 2 +- web/templates/index.html | 24 +- 92 files changed, 5685 insertions(+), 1454 deletions(-) create mode 100644 .github/workflows/pi-runtime.yml create mode 100644 cmd/build-runtime/main.go create mode 100644 docs/en-US/pi-runtime.md create mode 100644 docs/zh-CN/pi-runtime.md create mode 100644 internal/agentfinalizer/runtime_status_test.go create mode 100644 internal/agentruntime/build.go create mode 100644 internal/agentruntime/build_test.go create mode 100644 internal/agentruntime/pi.go create mode 100644 internal/agentruntime/pi_test.go create mode 100644 internal/agentruntime/session_lock.go create mode 100644 internal/agentruntime/types.go create mode 100644 internal/config/agent_runtime.go create mode 100644 internal/config/agent_runtime_test.go delete mode 100644 internal/knowledge/chunk_eino.go create mode 100644 internal/knowledge/document.go delete mode 100644 internal/knowledge/eino_retrieve_chain.go create mode 100644 internal/knowledge/embedding_http.go rename internal/knowledge/{eino_meta.go => metadata.go} (94%) rename internal/knowledge/{eino_meta_test.go => metadata_test.go} (100%) create mode 100644 internal/knowledge/native_pipeline_test.go rename internal/knowledge/{eino_pipeline_retriever.go => retrieval_pipeline.go} (66%) create mode 100644 internal/knowledge/retrieve_chain.go rename internal/knowledge/{eino_retrieve_chain_test.go => retrieve_chain_test.go} (100%) create mode 100644 internal/knowledge/splitter.go rename internal/knowledge/{eino_sqlite_indexer.go => sqlite_indexer.go} (85%) rename internal/knowledge/{eino_retriever_adapter.go => vector_retriever.go} (67%) create mode 100644 internal/multiagent/pi_integration_test.go create mode 100644 internal/multiagent/pi_local_tools.go create mode 100644 internal/multiagent/pi_model.go create mode 100644 internal/multiagent/pi_plan_tools.go create mode 100644 internal/multiagent/pi_plan_tools_test.go create mode 100644 internal/multiagent/pi_runner.go create mode 100644 internal/multiagent/runtime_dispatch.go delete mode 100644 internal/workflow/agent_subgraph.go delete mode 100644 internal/workflow/eino_branch.go delete mode 100644 internal/workflow/eino_callbacks.go delete mode 100644 internal/workflow/eino_compile.go create mode 100644 internal/workflow/native_runtime.go create mode 100644 internal/workflow/resume.go rename internal/workflow/{eino_runtime.go => runtime.go} (100%) rename internal/workflow/{eino_compile_test.go => runtime_test.go} (71%) create mode 100644 runtime/pi/.gitignore create mode 100644 runtime/pi/THIRD_PARTY_NOTICES.md create mode 100644 runtime/pi/package-lock.json create mode 100644 runtime/pi/package.json create mode 100644 runtime/pi/src/protocol.ts create mode 100644 runtime/pi/src/worker.ts create mode 100644 runtime/pi/test/worker.test.mjs create mode 100644 runtime/pi/tsconfig.json create mode 100755 scripts/build-pi-runtime.sh diff --git a/.github/workflows/pi-runtime.yml b/.github/workflows/pi-runtime.yml new file mode 100644 index 00000000..c57dbbe2 --- /dev/null +++ b/.github/workflows/pi-runtime.yml @@ -0,0 +1,27 @@ +name: Pi runtime +on: + push: + paths: ['runtime/pi/**', 'internal/agentruntime/**', 'internal/multiagent/**', 'internal/knowledge/**', 'internal/workflow/**', 'internal/config/**', 'cmd/build-runtime/**', 'scripts/build-pi-runtime.sh', '.github/workflows/pi-runtime.yml'] + pull_request: + paths: ['runtime/pi/**', 'internal/agentruntime/**', 'internal/multiagent/**', 'internal/knowledge/**', 'internal/workflow/**', 'internal/config/**', 'cmd/build-runtime/**', 'scripts/build-pi-runtime.sh', '.github/workflows/pi-runtime.yml'] +permissions: + contents: read +jobs: + runtime: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.19.0' + cache: npm + cache-dependency-path: runtime/pi/package-lock.json + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: npm ci --ignore-scripts + working-directory: runtime/pi + - run: npm run build && npm test + working-directory: runtime/pi + - run: go run ./cmd/build-runtime + - run: go test -race ./internal/agentruntime ./internal/multiagent ./internal/knowledge ./internal/workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 67168d31..4bb52123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Added +- 默认 Agent 运行时迁移为 Pi 1.5.2:Go 工具网关继续处理角色、审批、监控与证据;聊天与子代理的模型提案接入 Pi。新增按源码指纹发布的 Node Worker、流式协议、会话恢复、原生工具检索。部署需 Node.js >=22.19;保留显式 Eino Agent 回退。 +- Workflow 改用原生 DAG 与版本化检查点,知识库改用自身文档、切分、Embedding 和检索组件。旧 Eino Workflow 检查点需由原版本恢复,详见 [Pi 迁移说明](docs/zh-CN/pi-runtime.md)。 + - AI 渠道支持自定义 HTTP 请求头、环境变量和会话 ID 占位符;保存后的鉴权头可安全用于连接测试,独立审计身份隔离主渠道请求头,自定义头不会跟随跨源重定向。 - 支持通过 `agent.task_timeout_minutes` 配置单代理、多代理、工作流及异步任务执行超时:未配置默认 600 分钟,`0` 表示不限时,负数拒绝加载;续跑保留重新计时行为,单次工具超时仍独立生效。 diff --git a/cmd/build-runtime/main.go b/cmd/build-runtime/main.go new file mode 100644 index 00000000..bacaf51f --- /dev/null +++ b/cmd/build-runtime/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + + "cyberstrike-ai/internal/agentruntime" +) + +func main() { + root := flag.String("root", "runtime/pi", "Pi runtime source directory") + flag.Parse() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + path, err := agentruntime.PreparePiRuntime(ctx, *root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(path) +} diff --git a/config.example.yaml b/config.example.yaml index a3f10aff..d2ced3d6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -168,6 +168,13 @@ shodan: api_key: "" # Shodan API Key;也可通过 SHODAN_API_KEY 环境变量配置 # Agent 配置 # 达到最大迭代次数时,AI 会自动总结测试结果 +# Pi runtime (Node.js >=22.19). Build with: bash scripts/build-pi-runtime.sh +agent_runtime: + engine: pi # pi (default) | eino (legacy agent fallback) + node_path: node + worker_path: "" # empty: use the source-addressed runtime bundle + session_dir: data/pi-sessions + agent: # 修改后重启服务,对新任务生效;例如 1440 = 24 小时,单次工具超时独立生效。 task_timeout_minutes: 600 # 任务执行超时(分钟);未配置=600,0=不限时,负数无效;续跑重新计时 diff --git a/docs/en-US/README.md b/docs/en-US/README.md index 237d12eb..531c277f 100644 --- a/docs/en-US/README.md +++ b/docs/en-US/README.md @@ -33,3 +33,5 @@ - [Developer Guide](developer-guide.md) · [Plugin Development](plugin-development.md) · [Frontend i18n](frontend-i18n.md) - [Testing](testing.md) · [Contributing](contributing-guide.md) · [Release Process](release-process.md) + +- [Pi runtime and migration](pi-runtime.md) diff --git a/docs/en-US/pi-runtime.md b/docs/en-US/pi-runtime.md new file mode 100644 index 00000000..5aa76be6 --- /dev/null +++ b/docs/en-US/pi-runtime.md @@ -0,0 +1,26 @@ +# Pi runtime + +CyberStrikeAI uses Pi 1.5.2 by default. Go owns business state, permissions, MCP execution, evidence, and delivery checks; an isolated Node worker hosts the Pi SDK. + +## Build + +Node.js >=22.19 and the Go version in go.mod are required: + +```sh +bash scripts/build-pi-runtime.sh +go build -o cyberstrike-ai ./cmd/server +``` + +The launcher and self-update build prepare the worker. Dependency installation uses the lockfile with lifecycle scripts disabled. Complete bundles are published under `runtime/pi/.builds/`; running workers keep their original dependencies, and source rollback selects the corresponding old bundle. Retain the runtime sources and matching bundle when deploying, or rebuild on the destination. A Go binary alone is insufficient for Pi. + +## Configuration + +`agent_runtime.engine` defaults to `pi`; `eino` explicitly selects the legacy Agent fallback. Optional `node_path`, `worker_path`, and `session_dir` control deployment. Settings exposes runtime selection. + +The guidance combines capability selection, main/child task anchors and evidence requirements. It does not ship DSH host patches or encrypted identity prompts. Attribution is in `runtime/pi/THIRD_PARTY_NOTICES.md`. + +Chat agents, specialists and workflow Agent nodes use Pi. Large catalogs can use native tool search. Conversation task boards remain compatible with the existing UI. + +Workflow now uses a native DAG and versioned JSON checkpoints. Old Eino Workflow binary checkpoints require their original application version; `engine: eino` is an Agent fallback, not a converter for old Workflow checkpoints. Knowledge retrieval and indexing use native domain types and HTTP embeddings. Legacy Eino dependencies are still retained for the explicit Agent fallback. + +See the [Chinese implementation and validation guide](../zh-CN/pi-runtime.md) for protocol, recovery and test details. Local fixtures establish functional behavior, not live-provider reliability, refusal-rate improvements or production acceptance. diff --git a/docs/en-US/release-process.md b/docs/en-US/release-process.md index 883fa7cc..4709583b 100644 --- a/docs/en-US/release-process.md +++ b/docs/en-US/release-process.md @@ -53,6 +53,7 @@ SQLite migrations must be: ```bash go test ./internal/... go test ./cmd/... +bash scripts/build-pi-runtime.sh go build -o cyberstrike-ai ./cmd/server ``` diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 09e51bc7..2e8f843f 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -33,3 +33,5 @@ - [开发者指南](developer-guide.md) · [插件开发](plugin-development.md) · [前端国际化](frontend-i18n.md) - [测试指南](testing.md) · [贡献规范](contributing-guide.md) · [发布流程](release-process.md) + +- [Pi 原生运行时与迁移](pi-runtime.md) diff --git a/docs/zh-CN/architecture.md b/docs/zh-CN/architecture.md index 1520980a..74fb1a5e 100644 --- a/docs/zh-CN/architecture.md +++ b/docs/zh-CN/architecture.md @@ -58,14 +58,14 @@ Handler 负责参数解析、权限中间件后的业务协调和 HTTP 响应。 - `internal/agents/` - `agents/` -Eino ADK 提供单代理、Deep、Plan-Execute、Supervisor 等执行模式。多代理子 Agent 由 Markdown 文件定义。 +默认运行时是 Pi TypeScript SDK。`internal/agentruntime/` 管理子进程协议与构建,`internal/multiagent/pi_*.go` 负责角色、工具、审批和事件适配。单代理及 Deep、Plan-Execute、Supervisor 均使用 Pi 会话;角色仍由 Markdown 定义。旧 Eino 内核仅在 `agent_runtime.engine: eino` 时启用,供迁移回退。详见 [Pi 运行时](pi-runtime.md)。 ## MCP 与工具 MCP 相关: - `internal/mcp/`:Server、外部 MCP、连接恢复。 -- `internal/einomcp/`:Eino 与 MCP 工具适配。 +- `internal/agentruntime/` 与 `internal/multiagent/pi_runner.go`:Pi 与 Go MCP 执行链适配;`internal/einomcp/` 留作 Eino 兼容路径。 - `tools/`:YAML 命令工具。 - `internal/app/*_tools.go`:Go 内置工具注册。 @@ -73,7 +73,7 @@ MCP 相关: ## Workflow -工作流引擎在 `internal/workflow/`,HTTP 入口在 `internal/handler/workflow*.go`。它支持 start、agent、tool、condition、hitl、output、end 等节点。 +工作流引擎在 `internal/workflow/`,HTTP 入口在 `internal/handler/workflow*.go`。原生 DAG 执行器支持 start、agent、tool、condition、hitl、output、end 等节点,使用版本化 JSON 检查点保存恢复状态。 详细使用见 [工作流使用说明](workflow-graph.md)。 @@ -128,8 +128,8 @@ MCP 相关: 1. Gin 路由进入认证中间件。 2. Handler 解析请求体、会话 ID、角色、附件和 WebShell 上下文。 3. Agent 构建模型输入,包括历史消息、角色提示、项目事实、工具列表。 -4. Eino Runner 调用模型。 -5. 模型需要工具时走 MCP Tool。 +4. Pi Worker 调用模型,Go 接收生命周期事件。 +5. 模型需要工具时通过双向协议回到 Go 工具网关,再进入 MCP 或原生工具。 6. 工具调用前可能触发 HITL。 7. 工具执行结果写入过程详情和监控。 8. 模型继续推理并生成最终回答。 diff --git a/docs/zh-CN/pi-runtime.md b/docs/zh-CN/pi-runtime.md new file mode 100644 index 00000000..2ebbe261 --- /dev/null +++ b/docs/zh-CN/pi-runtime.md @@ -0,0 +1,60 @@ +# Pi 原生运行时 + +CyberStrikeAI 默认使用 Pi 1.5.2(`@earendil-works/pi-coding-agent`、`pi-ai`),Go 保留业务、权限、工具执行和证据管理。 + +## 构建与使用 + +需要 Go(版本见 go.mod)和 Node.js >=22.19。 + +```sh +bash scripts/build-pi-runtime.sh +go build -o cyberstrike-ai ./cmd/server +``` + +`run.sh` 和服务自更新的构建步骤都会准备 Pi。构建使用锁文件和 `npm ci --ignore-scripts`;发布到 `runtime/pi/.builds/<源码指纹>/` 后才会被选用。正在运行的 Worker 使用原来的依赖目录;源码回滚时选择对应旧指纹的包。缓存属于构建产物,不提交到 Git。部署时需保留运行时源码和已构建的匹配包,或在目标机器重新构建;只复制 Go 二进制不足以运行 Pi。 + +```yaml +agent_runtime: + engine: pi + node_path: node + worker_path: "" + session_dir: data/pi-sessions +``` + +设置页可选择运行时。`worker_path` 是管理员的显式覆盖,适合开发或独立打包;留空使用源码指纹校验后的构建包。未填写 engine 的新代码默认使用 Pi;现有部署的运行数据和模型配置不会因此自动迁移或部署。 + +## 执行边界 + +- 聊天、机器人、批量任务、工作流 Agent 节点通过统一调度入口进入 Pi。HTTP 路由与 `eino_single` 等历史模式值暂保留兼容,模式与运行时分别选择。 +- Go 管理每次调用的 Worker,支持流式事件、工具回调、补充指令与取消;同一进程中的相同会话存储串行访问,不同会话独立运行。 +- Worker 使用显式模型、内存凭据和资源加载器,不自动发现个人 Pi 配置、全局插件或默认文件/Shell 工具。 +- 工具较多时使用 Pi 原生 tool_search 按需加载,减少模型一次接收的工具定义。TaskCreate/Update/Get/List 和 write_todos 继续写入现有对话任务板。 +- MCP、文件和 Shell 工具回到 Go,经角色过滤、HITL 和现有执行监控处理。子代理工具集合受主代理集合约束。 +- Deep 使用任务委派;Supervisor 提供专家委派和 exit;Plan-Execute 使用计划记录与执行子代理。最终文本仍需通过既有证据验收。 +- Pi 原生重试及虚拟模型路由承接聊天备用渠道。认证/配置错误不能用无界“继续”消除。 +- 上下文窗口和输出上限沿用渠道设置;输入预算用于 Pi 压缩阈值。压缩后的模型上下文和完整会话记录分别保留。 + +## 数据与兼容 + +- Pi Session 是模型会话及压缩记录的权威来源;SQLite 保存业务状态、工具记录和交付证据。两者通过会话和执行 ID 关联。 +- 同一个 Worker 收到重复工具调用 ID 时复用已完成结果;不同参数复用相同 ID 会被拒绝。 +- 进程中断后,未提交结果的调用标记为结果不确定。系统不把它自动当成成功,也不直接重放它;继续前须核对执行记录。这不是跨所有外部系统的“恰好一次”保证。 +- Eino **Agent** 回退仍可通过 `engine: eino` 显式选择,旧依赖尚未全量删除。 +- Workflow 已使用原生 DAG 和 JSON 检查点。旧 Eino Workflow 二进制检查点不能由新版直接恢复;应先完成旧工作流,或使用原版本完成恢复。新检查点记录图指纹和执行中的节点,拒绝用已修改的图或结果不确定的节点自动续跑。 +- 知识库使用自身 Document、切分、Embedding HTTP、向量检索、查询扩展和重排流程。向量与表结构保持兼容,新索引准备成功后在事务中替换旧行。 +- 本地测试和源码集成不等于真实模型、真实任务的长时验收。当前尚无拒答率、发现率或成本改进的实测结论。 + +## 回归 + +```sh +cd runtime/pi +npm ci --ignore-scripts +npm run build +npm test +cd ../.. +go test -race ./internal/agentruntime ./internal/multiagent ./internal/workflow ./internal/knowledge +go test ./... +node --test web/static/js/*.test.cjs +``` + +SDK 集成测试使用本地模拟模型和无副作用的工具夹具,覆盖 OpenAI/Claude 动作建议、审批编辑、子代理工具边界、流式输出、取消、进程中断及备用模型切换。 diff --git a/docs/zh-CN/release-process.md b/docs/zh-CN/release-process.md index 77d86d08..622beab5 100644 --- a/docs/zh-CN/release-process.md +++ b/docs/zh-CN/release-process.md @@ -8,7 +8,7 @@ - `README.md` 和 `README_CN.md` 的功能说明是否更新。 - `docs/` 是否补充新功能文档。 -- `config.yaml` 示例是否包含新增配置。 +- `config.example.yaml` 示例是否包含新增配置。 - OpenAPI 是否包含新增接口。 - 中英文 i18n 是否同步。 - 高风险功能是否有安全说明。 @@ -25,6 +25,7 @@ go test ./internal/... ```bash go test ./cmd/... +bash scripts/build-pi-runtime.sh go build -o cyberstrike-ai ./cmd/server ``` @@ -39,6 +40,7 @@ go build -o cyberstrike-ai ./cmd/server ## 构建 ```bash +bash scripts/build-pi-runtime.sh go build -o cyberstrike-ai ./cmd/server ``` @@ -47,7 +49,7 @@ go build -o cyberstrike-ai ./cmd/server - `cyberstrike-ai` - `web/templates/` - `web/static/` -- `config.yaml` 示例。 +- `config.example.yaml` 示例。 - `tools/` - `roles/` - `skills/` @@ -157,6 +159,7 @@ SQLite 迁移要考虑: ```bash go test ./internal/... go test ./cmd/... +bash scripts/build-pi-runtime.sh go build -o cyberstrike-ai ./cmd/server ``` diff --git a/go.mod b/go.mod index 7d319ebd..fde53f80 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,6 @@ require ( github.com/bytedance/sonic v1.15.0 github.com/cloudwego/eino v0.9.21 github.com/cloudwego/eino-ext/adk/backend/local v0.2.6 - github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b - github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b - github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b - github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7 github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4 github.com/cloudwego/eino-ext/components/model/openai v0.1.13 @@ -39,6 +35,7 @@ require ( go.uber.org/zap v1.26.0 golang.org/x/crypto v0.50.0 golang.org/x/net v0.53.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 golang.org/x/text v0.37.0 golang.org/x/time v0.14.0 @@ -117,6 +114,7 @@ require ( github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect + github.com/smarty/assertions v1.16.0 // indirect github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -142,7 +140,6 @@ require ( golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect google.golang.org/api v0.189.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect diff --git a/go.sum b/go.sum index cc4267ea..73ec3d96 100644 --- a/go.sum +++ b/go.sum @@ -79,32 +79,16 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/cloudwego/eino v0.9.14 h1:suNVibjtkPMiW7csFBdBqN3FRG0nRlCqoGKE7t0UDwY= -github.com/cloudwego/eino v0.9.14/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= github.com/cloudwego/eino v0.9.21 h1:kBAyAjsnPq5NDWjt0VdmP9HY/XVUHs05LoeqybudPRQ= github.com/cloudwego/eino v0.9.21/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= github.com/cloudwego/eino-ext/adk/backend/local v0.2.6 h1:pxKAWcveIBWrbz28H2bDAMyJZnhysa9bh2Kh5s7iH7s= github.com/cloudwego/eino-ext/adk/backend/local v0.2.6/go.mod h1:uJlpYclMA2ScgUlHWHu2om+DDguV2Lzc+TNulWBXqfo= -github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc= -github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg= -github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b h1:3owjV4nv+XRplavTeqFlCeAV4v7EHR2tIXDqLEmPc38= -github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b/go.mod h1:KVOVct4e2BQ7epDONW2QE1qU5+ccoh91FzJTs9vIJj0= -github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b h1:j8sj/5QiooV3LWphFDsJvyD/csWwupz+UKXeG+nqiNg= -github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo= -github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b h1:pOqupZQyc46rw2Z0HeybtTmSMTwqfTrbRuGDuDsNf2A= -github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc= -github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3 h1:nfO9jUPhIKTHDPyL7rso6y7Mx8cAmrIgkeJC6KqTuXI= -github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3/go.mod h1:/dX1P7HNC7D3/nYLDoXBvdKAFnGS4tBbtJ8NrkaXuUk= github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7 h1:oxvyuAdZLlKSWYrzXwXS63AokgC3TqSljrzCGNRi4JA= github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7/go.mod h1:LsIyLkRviQtfJsGYP6MX9PwqCX4jMtVxR4zR2A7ZB+g= -github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1 h1:bIMyUxEZdBEofWbXQCozfGxXCsbyNjpZcRMfW+Kt5u0= -github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1/go.mod h1:84jUcVAcmlGDRVX0ExABVJEfrIaplaAeRbjFYW9mzkI= github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4 h1:M5ODiB1UaYV0I/dL2dmgc/HfZ2WUBV0saM9Tkhk2IDg= github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4/go.mod h1:AfLNKxrTFFkJbCWSvOtqKK1ZXEp0hpe26CBQ8GSf5zo= github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM= github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ= -github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 h1:ES/xufN5eqJ3h+9tw/tq6F8kkgnAxBAHVUB6nqKsIDU= -github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6/go.mod h1:5Xj74dGrfHo1z7I07Fzp3SlTF7Bt4tss3A2FSt8SqQ4= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d h1:FaBHOMj/ZDBmtsI9UMoDMDu9CNZPbjJwK/O26JFuZ8k= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d/go.mod h1:6yg0Rv7B3jZCQPc/C5s13PNnVewQdaRxOp2OMru7Yw4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -278,8 +262,6 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= -github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/meguminnnnnnnnn/go-openai v0.1.6 h1:c74nk9F3+/agOd09eHeQt/Ayty4pw54X2Dj5JHdxcys= github.com/meguminnnnnnnnn/go-openai v0.1.6/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= @@ -308,8 +290,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/openai/openai-go/v3 v3.35.0 h1:109x3epXMSE423KW2euR506GGFezcEt0s87MoWejpH0= -github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/openai/openai-go/v3 v3.44.0 h1:kkGh+jb/sKfSh5P74Jk5mCRufaQ0q7oH+lq+pNlWjsk= github.com/openai/openai-go/v3 v3.44.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= diff --git a/internal/agentfinalizer/decision.go b/internal/agentfinalizer/decision.go index a050ab22..c546400e 100644 --- a/internal/agentfinalizer/decision.go +++ b/internal/agentfinalizer/decision.go @@ -59,6 +59,12 @@ type Input struct { func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision { if result != nil { + if in.Status == "" && result.RuntimeStatus != "" { + in.Status = result.RuntimeStatus + } + if in.CompletionReason == "" && result.RuntimeCompletionReason != "" { + in.CompletionReason = result.RuntimeCompletionReason + } if strings.TrimSpace(in.Response) == "" { in.Response = result.Response } diff --git a/internal/agentfinalizer/runtime_status_test.go b/internal/agentfinalizer/runtime_status_test.go new file mode 100644 index 00000000..09cbbc0a --- /dev/null +++ b/internal/agentfinalizer/runtime_status_test.go @@ -0,0 +1,16 @@ +package agentfinalizer + +import ( + "cyberstrike-ai/internal/multiagent" + "testing" +) + +func TestRuntimeIterationLimitCannotBecomeCompleted(t *testing.T) { + result := &multiagent.RunResult{Response: "Partial report", RuntimeStatus: "blocked", RuntimeCompletionReason: "iteration_limit"} + for i := 0; i < 2; i++ { + decision := FromRunResult(nil, result, Input{}) + if decision.Finalizable || decision.Status != "blocked" || decision.CompletionReason != "iteration_limit" { + t.Fatalf("unexpected delivery: %+v", decision) + } + } +} diff --git a/internal/agentruntime/build.go b/internal/agentruntime/build.go new file mode 100644 index 00000000..87461e9c --- /dev/null +++ b/internal/agentruntime/build.go @@ -0,0 +1,140 @@ +package agentruntime + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +func runtimeSourceFiles(root string) ([]string, error) { + files := []string{"package.json", "package-lock.json", "tsconfig.json"} + err := filepath.WalkDir(filepath.Join(root, "src"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("runtime source must not be a symlink: %s", path) + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + sort.Strings(files) + return files, err +} + +func SourceFingerprint(root string) (string, error) { + files, err := runtimeSourceFiles(root) + if err != nil { + return "", err + } + h := sha256.New() + for _, file := range files { + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(file))) + if err != nil { + return "", err + } + h.Write([]byte(file)) + h.Write([]byte{0}) + h.Write(b) + h.Write([]byte{0}) + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// PreparePiRuntime publishes an immutable source-addressed worker bundle. Source rollback +// selects the prior bundle automatically; an active worker keeps its original dependencies. +func PreparePiRuntime(ctx context.Context, root string) (string, error) { + root, err := filepath.Abs(root) + if err != nil { + return "", err + } + fingerprint, err := SourceFingerprint(root) + if err != nil { + return "", err + } + cache := filepath.Join(root, ".builds") + dest := filepath.Join(cache, fingerprint) + worker := filepath.Join(dest, "dist", "worker.js") + if _, err := os.Stat(filepath.Join(dest, ".ready")); err == nil { + if _, err = os.Stat(worker); err == nil { + return worker, nil + } + } + if err = os.MkdirAll(cache, 0o700); err != nil { + return "", err + } + stage, err := os.MkdirTemp(cache, ".staging-") + if err != nil { + return "", err + } + defer os.RemoveAll(stage) + files, err := runtimeSourceFiles(root) + if err != nil { + return "", err + } + for _, name := range files { + body, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) + if err != nil { + return "", err + } + path := filepath.Join(stage, filepath.FromSlash(name)) + if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + if err = os.WriteFile(path, body, 0o600); err != nil { + return "", err + } + } + if _, err = exec.LookPath("node"); err != nil { + return "", fmt.Errorf("Pi requires Node.js >=22.19: %w", err) + } + check := exec.CommandContext(ctx, "node", "-e", `const [a,b]=process.versions.node.split('.').map(Number);process.exit(a>22||(a===22&&b>=19)?0:1)`) + if err = check.Run(); err != nil { + return "", fmt.Errorf("Pi requires Node.js >=22.19") + } + for _, args := range [][]string{{"ci", "--ignore-scripts", "--no-audit", "--no-fund"}, {"run", "build"}} { + cmd := exec.CommandContext(ctx, "npm", args...) + if runtime.GOOS == "windows" { + cmd = exec.CommandContext(ctx, "cmd.exe", append([]string{"/d", "/c", "npm"}, args...)...) + } + cmd.Dir = stage + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + if err = cmd.Run(); err != nil { + msg := strings.TrimSpace(output.String()) + if len(msg) > 4000 { + msg = msg[len(msg)-4000:] + } + return "", fmt.Errorf("Pi runtime build failed: %w\n%s", err, msg) + } + } + if _, err = os.Stat(filepath.Join(stage, "dist", "worker.js")); err != nil { + return "", fmt.Errorf("Pi build produced no worker: %w", err) + } + if err = os.WriteFile(filepath.Join(stage, ".ready"), []byte(fingerprint), 0o600); err != nil { + return "", err + } + if err = os.Rename(stage, dest); err != nil { + if _, exists := os.Stat(filepath.Join(dest, ".ready")); exists == nil { + return worker, nil + } + return "", err + } + return worker, nil +} diff --git a/internal/agentruntime/build_test.go b/internal/agentruntime/build_test.go new file mode 100644 index 00000000..692a3aca --- /dev/null +++ b/internal/agentruntime/build_test.go @@ -0,0 +1,49 @@ +package agentruntime + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSourceAddressedWorkerRejectsStaleBundleAndSupportsRollback(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + runtimeRoot := filepath.Join(root, "runtime", "pi") + if err := os.MkdirAll(filepath.Join(runtimeRoot, "src"), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"package.json", "package-lock.json", "tsconfig.json"} { + if err := os.WriteFile(filepath.Join(runtimeRoot, name), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + } + source := filepath.Join(runtimeRoot, "src", "worker.ts") + os.WriteFile(source, []byte("version one"), 0o600) + hash, err := SourceFingerprint(runtimeRoot) + if err != nil { + t.Fatal(err) + } + bundle := filepath.Join(runtimeRoot, ".builds", hash) + os.MkdirAll(filepath.Join(bundle, "dist"), 0o700) + os.WriteFile(filepath.Join(bundle, "dist", "worker.js"), []byte("worker"), 0o600) + if _, err := ResolveWorker(""); err == nil { + t.Fatal("unpublished bundle was selected") + } + os.WriteFile(filepath.Join(bundle, ".ready"), []byte(hash), 0o600) + worker, err := ResolveWorker("") + if err != nil { + t.Fatal(err) + } + if worker != filepath.Join(bundle, "dist", "worker.js") { + t.Fatalf("wrong worker: %s", worker) + } + os.WriteFile(source, []byte("version two"), 0o600) + if _, err := ResolveWorker(""); err == nil { + t.Fatal("stale source bundle was selected") + } + os.WriteFile(source, []byte("version one"), 0o600) + if restored, err := ResolveWorker(""); err != nil || restored != worker { + t.Fatalf("rollback lost original bundle: %s %v", restored, err) + } +} diff --git a/internal/agentruntime/pi.go b/internal/agentruntime/pi.go new file mode 100644 index 00000000..fa096f41 --- /dev/null +++ b/internal/agentruntime/pi.go @@ -0,0 +1,309 @@ +package agentruntime + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "time" +) + +const maxRecordBytes = 32 * 1024 * 1024 + +type Pi struct { + NodePath string + WorkerPath string +} + +type runRequest struct { + Request + LegacyProfile json.RawMessage `json:"profile"` +} + +type record struct { + Type string `json:"type"` + Version int `json:"version,omitempty"` + Request *runRequest `json:"request,omitempty"` + Event *Event `json:"event,omitempty"` + Call *ToolCall `json:"call,omitempty"` + Result *Result `json:"result,omitempty"` + Error string `json:"error,omitempty"` + SDKVersion string `json:"sdkVersion,omitempty"` +} + +// ResolveWorker supports source installs and release bundles without relying on PATH for scripts. +func ResolveWorker(configured string) (string, error) { + if configured != "" { + path, err := filepath.Abs(configured) + if err != nil { + return "", err + } + if st, err := os.Stat(path); err != nil || !st.Mode().IsRegular() { + return "", fmt.Errorf("Pi worker does not exist: %s", path) + } + return path, nil + } + var candidates []string + if exe, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(exe), "runtime/pi/dist/worker.js")) + } + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(cwd, "runtime/pi/dist/worker.js")) + // Package tests and developer commands may run below the repository root. + for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + candidates = append(candidates, filepath.Join(dir, "runtime/pi/dist/worker.js")) + break + } + } + } + for _, path := range candidates { + root := filepath.Dir(filepath.Dir(path)) + if fingerprint, err := SourceFingerprint(root); err == nil { + bundle := filepath.Join(root, ".builds", fingerprint) + if _, err = os.Stat(filepath.Join(bundle, ".ready")); err == nil { + if _, err = os.Stat(filepath.Join(bundle, "dist", "worker.js")); err == nil { + return filepath.Join(bundle, "dist", "worker.js"), nil + } + } + continue + } + if st, err := os.Stat(path); err == nil && st.Mode().IsRegular() { + return path, nil + } + } + return "", errors.New("Pi worker missing; run bash scripts/build-pi-runtime.sh, or configure agent_runtime.worker_path") +} + +// Run owns one isolated worker and reaps it on every exit. Tool execution stays in the host. +func (p Pi) Run(ctx context.Context, req Request, execute ToolExecutor, emit func(Event), bind func(Control) func()) (*Result, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if req.WorkingDir == "" { + var err error + req.WorkingDir, err = os.Getwd() + if err != nil { + return nil, err + } + } + release, err := acquireSession(ctx, req.SessionDir) + if err != nil { + return nil, err + } + defer release() + worker, err := ResolveWorker(p.WorkerPath) + if err != nil { + return nil, err + } + node := p.NodePath + if node == "" { + node = "node" + } + scratchParent, err := os.MkdirTemp("", "cyberstrike-pi-host-") + if err != nil { + return nil, err + } + defer os.RemoveAll(scratchParent) + cmd := exec.Command(node, worker) + cmd.Env = append(os.Environ(), "CYBERSTRIKE_PI_TMP_PARENT="+scratchParent) + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + stdin.Close() + return nil, err + } + // Worker protocol carries failures. Never forward arbitrary stderr (which may include provider secrets). + cmd.Stderr = io.Discard + if err = cmd.Start(); err != nil { + stdin.Close() + stdout.Close() + return nil, fmt.Errorf("start Pi worker: %w", err) + } + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + ioCtx, stopIO := context.WithCancel(context.Background()) + defer stopIO() + var writeMu sync.Mutex + write := func(v any) error { + body, err := json.Marshal(v) + if err != nil { + return err + } + if len(body) > maxRecordBytes { + return errors.New("Pi request exceeds protocol record limit") + } + writeMu.Lock() + defer writeMu.Unlock() + _, err = stdin.Write(append(body, '\n')) + return err + } + incoming := make(chan record, 32) + readDone := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), maxRecordBytes+1) + for scanner.Scan() { + var r record + if err := json.Unmarshal(scanner.Bytes(), &r); err != nil { + readDone <- fmt.Errorf("invalid Pi protocol JSON: %w", err) + return + } + select { + case incoming <- r: + case <-ioCtx.Done(): + readDone <- ioCtx.Err() + return + } + } + readDone <- scanner.Err() + }() + defer func() { + cancel() + stopIO() + stdin.Close() + _ = cmd.Process.Kill() + _ = cmd.Wait() + stdout.Close() + }() + readyTimeout := time.NewTimer(20 * time.Second) + defer readyTimeout.Stop() + select { + case r := <-incoming: + if r.Type != "ready" || r.Version != ProtocolVersion || r.SDKVersion != "1.5.2" { + return nil, errors.New("Pi runtime protocol mismatch") + } + case err := <-readDone: + return nil, fmt.Errorf("Pi worker exited before handshake: %v", err) + case <-readyTimeout.C: + return nil, errors.New("Pi worker startup timeout") + case <-ctx.Done(): + return nil, ctx.Err() + } + // Older protocol v1 workers require a profile object. Keep it disabled. + run := runRequest{Request: req, LegacyProfile: json.RawMessage(`{"enabled":false}`)} + if err = write(record{Type: "run", Version: ProtocolVersion, Request: &run}); err != nil { + return nil, err + } + if bind != nil { + unbind := bind(Control{ + Steer: func(s string) error { return write(map[string]any{"type": "steer", "message": s}) }, + Cancel: func() error { cancel(); return write(map[string]any{"type": "cancel"}) }, + }) + if unbind != nil { + defer unbind() + } + } + type executed struct { + call string + result ToolResult + done chan struct{} + } + var toolMu sync.Mutex + toolCache := map[string]*executed{} + toolSlots := make(chan struct{}, 32) + ctxDone := ctx.Done() + var cancelDeadline <-chan time.Time + var cancelTimer *time.Timer + defer func() { + if cancelTimer != nil { + cancelTimer.Stop() + } + }() + for { + select { + case r := <-incoming: + switch r.Type { + case "event": + if emit != nil && r.Event != nil { + emit(*r.Event) + } + case "tool_call": + if r.Call == nil || r.Call.ID == "" { + return nil, errors.New("Pi tool call has no identity") + } + call := *r.Call + select { + case toolSlots <- struct{}{}: + default: + return nil, errors.New("Pi tool concurrency limit exceeded") + } + go func() { + defer func() { <-toolSlots }() + body, _ := json.Marshal(call) + toolMu.Lock() + cached := toolCache[call.ID] + if cached == nil { + cached = &executed{call: string(body), done: make(chan struct{})} + toolCache[call.ID] = cached + toolMu.Unlock() + res := ToolResult{IsError: true, Content: "No host tool executor"} + if execute != nil { + var invokeErr error + res, invokeErr = execute(runCtx, call) + if invokeErr != nil { + res.IsError = true + res.Content = invokeErr.Error() + } + } + cached.result = res + close(cached.done) + } else { + toolMu.Unlock() + } + select { + case <-cached.done: + case <-runCtx.Done(): + return + } + res := cached.result + if cached.call != string(body) { + res = ToolResult{IsError: true, Content: "Tool call ID reused with different arguments"} + } + _ = write(map[string]any{"type": "tool_result", "id": call.ID, "result": res}) + }() + case "result": + if r.Result == nil { + return nil, errors.New("Pi returned an empty result") + } + if ctx.Err() != nil { + return r.Result, ctx.Err() + } + if r.Result.Error != "" { + return r.Result, fmt.Errorf("Pi runtime error: %s", r.Result.Error) + } + if r.Result.Status == "canceled" { + return r.Result, context.Canceled + } + return r.Result, nil + case "fatal": + return nil, fmt.Errorf("Pi protocol failure: %s", r.Error) + default: + return nil, fmt.Errorf("unexpected Pi record: %s", r.Type) + } + case err := <-readDone: + // All queued protocol records precede EOF; process them before interpreting exit. + if len(incoming) > 0 { + readDone <- err + continue + } + return nil, fmt.Errorf("Pi worker exited without a result: %v", err) + case <-ctxDone: + ctxDone = nil + _ = write(map[string]any{"type": "cancel"}) + cancelTimer = time.NewTimer(2 * time.Second) + cancelDeadline = cancelTimer.C + case <-cancelDeadline: + return nil, ctx.Err() + } + } +} diff --git a/internal/agentruntime/pi_test.go b/internal/agentruntime/pi_test.go new file mode 100644 index 00000000..5eac1acb --- /dev/null +++ b/internal/agentruntime/pi_test.go @@ -0,0 +1,122 @@ +package agentruntime + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func fixtureWorker(t *testing.T, body string) Pi { + t.Helper() + node, err := exec.LookPath("node") + if err != nil { + t.Skip("Node is unavailable") + } + path := filepath.Join(t.TempDir(), "worker.cjs") + script := `const readline=require('node:readline'); +const send=x=>process.stdout.write(JSON.stringify(x)+'\n'); +send({type:'ready',version:1,sdkVersion:'1.5.2'}); +const input=readline.createInterface({input:process.stdin}); +` + body + if err = os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + return Pi{NodePath: node, WorkerPath: path} +} + +func TestPiHostDeduplicatesToolCalls(t *testing.T) { + p := fixtureWorker(t, `let replies=0;input.on('line',line=>{const r=JSON.parse(line);if(r.type==='run'){ +const call={id:'same-id',name:'echo',arguments:{text:'hello'}};send({type:'tool_call',call});send({type:'tool_call',call}); +}else if(r.type==='tool_result' && ++replies===2){send({type:'result',result:{status:'completed',response:r.result.content,history:[]}});}});`) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var calls atomic.Int32 + res, err := p.Run(ctx, Request{RunID: "test"}, func(context.Context, ToolCall) (ToolResult, error) { + calls.Add(1) + time.Sleep(20 * time.Millisecond) + return ToolResult{Content: "hello"}, nil + }, nil, nil) + if err != nil { + t.Fatal(err) + } + if res.Response != "hello" || calls.Load() != 1 { + t.Fatalf("result=%+v calls=%d", res, calls.Load()) + } +} + +func TestPiHostDisablesProfileForLegacyWorkers(t *testing.T) { + p := fixtureWorker(t, `input.on('line', line => { + const record = JSON.parse(line); + if (record.type !== 'run') return; + if (record.request.profile.enabled !== false || record.request.profile.instruction !== undefined) { + process.exit(1); + } + send({type:'result',result:{status:'completed',response:record.request.systemPrompt,history:[]}}); +});`) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + const prompt = "Return the fixture result." + res, err := p.Run(ctx, Request{SystemPrompt: prompt}, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if res.Response != prompt { + t.Fatalf("expected the original system prompt, got %q", res.Response) + } +} + +func TestPiCancelReachesHostToolWithoutCancelingParent(t *testing.T) { + p := fixtureWorker(t, `input.on('line',line=>{const r=JSON.parse(line);if(r.type==='run')send({type:'tool_call',call:{id:'wait',name:'wait',arguments:{}}}); +if(r.type==='cancel')send({type:'result',result:{status:'canceled',response:'partial',history:[]}});});`) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var control Control + started := make(chan struct{}) + stopped := make(chan struct{}) + go func() { <-started; _ = control.Cancel() }() + res, err := p.Run(ctx, Request{RunID: "test"}, func(ctx context.Context, _ ToolCall) (ToolResult, error) { + close(started) + <-ctx.Done() + close(stopped) + return ToolResult{}, ctx.Err() + }, nil, func(c Control) func() { control = c; return func() {} }) + if !errors.Is(err, context.Canceled) || res == nil || res.Response != "partial" { + t.Fatalf("result=%+v err=%v", res, err) + } + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("host tool was not canceled") + } + if ctx.Err() != nil { + t.Fatal("parent context should remain active") + } +} + +func TestPiUnexpectedExitIsNotSuccess(t *testing.T) { + p := fixtureWorker(t, `input.on('line',()=>process.exit(0));`) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := p.Run(ctx, Request{}, nil, nil, nil); err == nil { + t.Fatal("worker exit without final result was accepted") + } +} + +func TestSessionLockWaitRespectsCancellation(t *testing.T) { + dir := t.TempDir() + release, err := acquireSession(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer release() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = acquireSession(ctx, dir); !errors.Is(err, context.Canceled) { + t.Fatalf("got %v", err) + } +} diff --git a/internal/agentruntime/session_lock.go b/internal/agentruntime/session_lock.go new file mode 100644 index 00000000..393707cd --- /dev/null +++ b/internal/agentruntime/session_lock.go @@ -0,0 +1,48 @@ +package agentruntime + +import ( + "context" + "path/filepath" + "sync" +) + +type sessionLock struct { + token chan struct{} + refs int +} + +var sessionLockMu sync.Mutex +var sessionLocks = map[string]*sessionLock{} + +func acquireSession(ctx context.Context, dir string) (func(), error) { + if dir == "" { + return func() {}, nil + } + key, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + sessionLockMu.Lock() + lock := sessionLocks[key] + if lock == nil { + lock = &sessionLock{token: make(chan struct{}, 1)} + sessionLocks[key] = lock + } + lock.refs++ + sessionLockMu.Unlock() + remove := func() { + sessionLockMu.Lock() + defer sessionLockMu.Unlock() + lock.refs-- + if lock.refs == 0 { + delete(sessionLocks, key) + } + } + select { + case lock.token <- struct{}{}: + return func() { <-lock.token; remove() }, nil + case <-ctx.Done(): + remove() + return nil, ctx.Err() + } +} diff --git a/internal/agentruntime/types.go b/internal/agentruntime/types.go new file mode 100644 index 00000000..087a34b4 --- /dev/null +++ b/internal/agentruntime/types.go @@ -0,0 +1,83 @@ +// Package agentruntime defines the host-owned, framework-independent Agent protocol. +package agentruntime + +import ( + "context" + "encoding/json" +) + +const ProtocolVersion = 1 + +type Model struct { + Provider string `json:"provider"` + API string `json:"api,omitempty"` + ID string `json:"id"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + Headers map[string]string `json:"headers,omitempty"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` + Thinking string `json:"thinking,omitempty"` + ExtraFields map[string]any `json:"extraFields,omitempty"` + Compat map[string]any `json:"compat,omitempty"` + InputBudget int `json:"inputBudget,omitempty"` +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` +} + +type Request struct { + GenerateOnly bool `json:"generateOnly,omitempty"` + RunID string `json:"runId"` + ConversationID string `json:"conversationId"` + WorkingDir string `json:"workingDir,omitempty"` + SystemPrompt string `json:"systemPrompt"` + Message string `json:"message"` + History json.RawMessage `json:"history,omitempty"` + Model Model `json:"model"` + FallbackModels []Model `json:"fallbackModels,omitempty"` + RetryMax int `json:"retryMax,omitempty"` + Tools []Tool `json:"tools"` + ToolSearch bool `json:"toolSearch"` + ToolSearchMinTools int `json:"toolSearchMinTools,omitempty"` + AlwaysVisibleTools []string `json:"alwaysVisibleTools,omitempty"` + SessionDir string `json:"sessionDir,omitempty"` + MaxTurns int `json:"maxTurns"` +} + +type Event struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` +} + +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +type ToolResult struct { + Content string `json:"content"` + IsError bool `json:"isError,omitempty"` + ExecutionID string `json:"executionId,omitempty"` + Terminate bool `json:"terminate,omitempty"` +} + +type Result struct { + TotalTokens int `json:"totalTokens,omitempty"` + Response string `json:"response"` + History json.RawMessage `json:"history"` + Status string `json:"status"` + SessionFile string `json:"sessionFile,omitempty"` + Error string `json:"error,omitempty"` +} + +type ToolExecutor func(context.Context, ToolCall) (ToolResult, error) + +type Control struct { + Steer func(string) error + Cancel func() error +} diff --git a/internal/app/app.go b/internal/app/app.go index 891c22c1..035de694 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -79,6 +79,9 @@ type App struct { // New 创建新应用 func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) { + if err := cfg.AgentRuntime.Validate(); err != nil { + return nil, err + } toolGuard, err := toolguard.NewManager(cfg.EffectiveToolGuard()) if err != nil { return nil, fmt.Errorf("初始化调用拦截规则: %w", err) diff --git a/internal/config/agent_runtime.go b/internal/config/agent_runtime.go new file mode 100644 index 00000000..a7b10f0e --- /dev/null +++ b/internal/config/agent_runtime.go @@ -0,0 +1,25 @@ +package config + +import ( + "fmt" + "strings" +) + +// AgentRuntimeConfig keeps runtime selection separate from model/provider selection. +type AgentRuntimeConfig struct { + Engine string `yaml:"engine,omitempty" json:"engine,omitempty"` + NodePath string `yaml:"node_path,omitempty" json:"node_path,omitempty"` + WorkerPath string `yaml:"worker_path,omitempty" json:"worker_path,omitempty"` + SessionDir string `yaml:"session_dir,omitempty" json:"session_dir,omitempty"` +} + +func (c AgentRuntimeConfig) UsesPi() bool { + return strings.TrimSpace(c.Engine) == "" || strings.EqualFold(strings.TrimSpace(c.Engine), "pi") +} +func (c AgentRuntimeConfig) Validate() error { + engine := strings.ToLower(strings.TrimSpace(c.Engine)) + if engine != "" && engine != "pi" && engine != "eino" { + return fmt.Errorf("unknown agent runtime %q (expected pi or eino)", c.Engine) + } + return nil +} diff --git a/internal/config/agent_runtime_test.go b/internal/config/agent_runtime_test.go new file mode 100644 index 00000000..842c7792 --- /dev/null +++ b/internal/config/agent_runtime_test.go @@ -0,0 +1,15 @@ +package config + +import "testing" + +func TestAgentRuntimeDefaultAndExplicitFallback(t *testing.T) { + if !(AgentRuntimeConfig{}).UsesPi() { + t.Fatal("Pi must be the default") + } + if (AgentRuntimeConfig{Engine: "eino"}).UsesPi() { + t.Fatal("explicit fallback ignored") + } + if (AgentRuntimeConfig{Engine: "typo"}).Validate() == nil { + t.Fatal("invalid runtime accepted") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e5af521a..f276c5c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,37 +21,38 @@ import ( ) type Config struct { - Version string `yaml:"version,omitempty" json:"version,omitempty"` // 前端显示的版本号,如 v1.3.3 - Server ServerConfig `yaml:"server"` - Log LogConfig `yaml:"log"` - MCP MCPConfig `yaml:"mcp"` - AI AIConfig `yaml:"ai,omitempty" json:"ai,omitempty"` - OpenAI OpenAIConfig `yaml:"openai,omitempty" json:"openai,omitempty"` - FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"` - ZoomEye SpaceSearchConfig `yaml:"zoomeye,omitempty" json:"zoomeye,omitempty"` - Quake SpaceSearchConfig `yaml:"quake,omitempty" json:"quake,omitempty"` - Shodan SpaceSearchConfig `yaml:"shodan,omitempty" json:"shodan,omitempty"` - Agent AgentConfig `yaml:"agent"` - Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"` - ToolGuard *toolguard.Config `yaml:"tool_guard,omitempty" json:"tool_guard,omitempty"` - Security SecurityConfig `yaml:"security"` - Database DatabaseConfig `yaml:"database"` - Auth AuthConfig `yaml:"auth"` - Audit AuditConfig `yaml:"audit,omitempty" json:"audit,omitempty"` - Monitor MonitorConfig `yaml:"monitor,omitempty" json:"monitor,omitempty"` - Storage StorageConfig `yaml:"storage,omitempty" json:"storage,omitempty"` - ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"` - Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"` - C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用 - Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置 - RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式) - Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色 - SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录 - AgentsDir string `yaml:"agents_dir,omitempty" json:"agents_dir,omitempty"` // 多代理子 Agent Markdown 定义目录(*.md,YAML front matter) - MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"` - Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"` - Vision VisionConfig `yaml:"vision,omitempty" json:"vision,omitempty"` - Update UpdateConfig `yaml:"update,omitempty" json:"update,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` // 前端显示的版本号,如 v1.3.3 + Server ServerConfig `yaml:"server"` + Log LogConfig `yaml:"log"` + MCP MCPConfig `yaml:"mcp"` + AI AIConfig `yaml:"ai,omitempty" json:"ai,omitempty"` + OpenAI OpenAIConfig `yaml:"openai,omitempty" json:"openai,omitempty"` + FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"` + ZoomEye SpaceSearchConfig `yaml:"zoomeye,omitempty" json:"zoomeye,omitempty"` + Quake SpaceSearchConfig `yaml:"quake,omitempty" json:"quake,omitempty"` + Shodan SpaceSearchConfig `yaml:"shodan,omitempty" json:"shodan,omitempty"` + Agent AgentConfig `yaml:"agent"` + AgentRuntime AgentRuntimeConfig `yaml:"agent_runtime,omitempty" json:"agent_runtime,omitempty"` + Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"` + ToolGuard *toolguard.Config `yaml:"tool_guard,omitempty" json:"tool_guard,omitempty"` + Security SecurityConfig `yaml:"security"` + Database DatabaseConfig `yaml:"database"` + Auth AuthConfig `yaml:"auth"` + Audit AuditConfig `yaml:"audit,omitempty" json:"audit,omitempty"` + Monitor MonitorConfig `yaml:"monitor,omitempty" json:"monitor,omitempty"` + Storage StorageConfig `yaml:"storage,omitempty" json:"storage,omitempty"` + ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"` + Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"` + C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用 + Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置 + RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式) + Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色 + SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录 + AgentsDir string `yaml:"agents_dir,omitempty" json:"agents_dir,omitempty"` // 多代理子 Agent Markdown 定义目录(*.md,YAML front matter) + MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"` + Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"` + Vision VisionConfig `yaml:"vision,omitempty" json:"vision,omitempty"` + Update UpdateConfig `yaml:"update,omitempty" json:"update,omitempty"` } // UpdateConfig 一键更新的更新源(可选):一个仓库地址,更新跟随该仓库的默认分支。 diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 0e5907ec..a811afad 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -794,7 +794,7 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry( assistantMessageID string, taskStatus *string, ) (string, string, error) { - resultMA, errMA := multiagent.RunEinoSingleChatModelAgent( + resultMA, errMA := multiagent.RunSingleAgent( taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, history, roleTools, progressCallback, nil, h.agentSessionContextBlock(conversationID), ) @@ -814,7 +814,7 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry( assistantMessageID string, taskStatus *string, ) (string, string, error) { - resultMA, errMA := multiagent.RunDeepAgent( + resultMA, errMA := multiagent.RunMultiAgent( taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, history, roleTools, progressCallback, h.agentsMarkdownDir, orchestration, nil, h.agentSessionContextBlock(conversationID), diff --git a/internal/handler/agent_multi_json_runner.go b/internal/handler/agent_multi_json_runner.go index 930549ae..05f13eec 100644 --- a/internal/handler/agent_multi_json_runner.go +++ b/internal/handler/agent_multi_json_runner.go @@ -37,7 +37,7 @@ func (h *AgentHandler) runMultiJSON(taskCtx, baseCtx context.Context, cancelWith var decision agentfinalizer.Decision var autoCancelledPendingExecutionIDs []string for { - result, runErr = multiagent.RunDeepAgent( + result, runErr = multiagent.RunMultiAgent( taskCtx, runCfg, &runCfg.MultiAgent, diff --git a/internal/handler/agent_single_json_runner.go b/internal/handler/agent_single_json_runner.go index c84e93cd..eec22150 100644 --- a/internal/handler/agent_single_json_runner.go +++ b/internal/handler/agent_single_json_runner.go @@ -33,7 +33,7 @@ func (h *AgentHandler) runSingleJSON(taskCtx, baseCtx context.Context, cancelWit var decision agentfinalizer.Decision var autoCancelledPendingExecutionIDs []string for { - result, runErr = multiagent.RunEinoSingleChatModelAgent( + result, runErr = multiagent.RunSingleAgent( taskCtx, runCfg, &runCfg.MultiAgent, diff --git a/internal/handler/batch_queue_executor.go b/internal/handler/batch_queue_executor.go index be950dfe..dc98eb57 100644 --- a/internal/handler/batch_queue_executor.go +++ b/internal/handler/batch_queue_executor.go @@ -269,12 +269,12 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu var runErr error switch { case useBatchMulti: - resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.agentSessionContextBlock(conversationID)) + resultMA, runErr = multiagent.RunMultiAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.agentSessionContextBlock(conversationID)) default: if h.config == nil { runErr = fmt.Errorf("服务器配置未加载") } else { - resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.agentSessionContextBlock(conversationID)) + resultMA, runErr = multiagent.RunSingleAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.agentSessionContextBlock(conversationID)) } } diff --git a/internal/handler/config.go b/internal/handler/config.go index d018552e..9005c212 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -18,7 +18,6 @@ import ( "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/knowledge" - "cyberstrike-ai/internal/llm" "cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/openai" @@ -26,7 +25,6 @@ import ( "cyberstrike-ai/internal/toolguard" "cyberstrike-ai/internal/typesafe" - "github.com/cloudwego/eino/schema" "github.com/gin-gonic/gin" "go.uber.org/zap" "gopkg.in/yaml.v3" @@ -264,21 +262,22 @@ func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) err // GetConfigResponse 获取配置响应 type GetConfigResponse struct { - AI config.AIConfig `json:"ai"` - OpenAI config.OpenAIConfig `json:"openai"` - Vision config.VisionConfig `json:"vision"` - FOFA config.FofaConfig `json:"fofa"` - ZoomEye config.SpaceSearchConfig `json:"zoomeye"` - Quake config.SpaceSearchConfig `json:"quake"` - Shodan config.SpaceSearchConfig `json:"shodan"` - MCP config.MCPConfig `json:"mcp"` - Tools []ToolConfigInfo `json:"tools"` - Agent config.AgentConfig `json:"agent"` - Hitl config.HitlConfig `json:"hitl,omitempty"` - Knowledge config.KnowledgeConfig `json:"knowledge"` - Robots config.RobotsConfig `json:"robots,omitempty"` - MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"` - C2 config.C2Public `json:"c2"` + AgentRuntime config.AgentRuntimeConfig `json:"agent_runtime"` + AI config.AIConfig `json:"ai"` + OpenAI config.OpenAIConfig `json:"openai"` + Vision config.VisionConfig `json:"vision"` + FOFA config.FofaConfig `json:"fofa"` + ZoomEye config.SpaceSearchConfig `json:"zoomeye"` + Quake config.SpaceSearchConfig `json:"quake"` + Shodan config.SpaceSearchConfig `json:"shodan"` + MCP config.MCPConfig `json:"mcp"` + Tools []ToolConfigInfo `json:"tools"` + Agent config.AgentConfig `json:"agent"` + Hitl config.HitlConfig `json:"hitl,omitempty"` + Knowledge config.KnowledgeConfig `json:"knowledge"` + Robots config.RobotsConfig `json:"robots,omitempty"` + MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"` + C2 config.C2Public `json:"c2"` } // ToolConfigInfo 工具配置信息 @@ -374,21 +373,22 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { } response, err := maskedConfigResponse(GetConfigResponse{ - AI: h.config.AI, - OpenAI: h.config.OpenAI, - Vision: h.config.Vision, - FOFA: h.config.FOFA, - ZoomEye: h.config.ZoomEye, - Quake: h.config.Quake, - Shodan: h.config.Shodan, - MCP: h.config.MCP, - Tools: tools, - Agent: h.config.Agent, - Hitl: h.config.Hitl, - Knowledge: h.config.Knowledge, - C2: h.config.C2.Public(), - Robots: h.config.Robots, - MultiAgent: multiPub, + AgentRuntime: h.config.AgentRuntime, + AI: h.config.AI, + OpenAI: h.config.OpenAI, + Vision: h.config.Vision, + FOFA: h.config.FOFA, + ZoomEye: h.config.ZoomEye, + Quake: h.config.Quake, + Shodan: h.config.Shodan, + MCP: h.config.MCP, + Tools: tools, + Agent: h.config.Agent, + Hitl: h.config.Hitl, + Knowledge: h.config.Knowledge, + C2: h.config.C2.Public(), + Robots: h.config.Robots, + MultiAgent: multiPub, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "配置序列化失败"}) @@ -723,22 +723,23 @@ func (h *ConfigHandler) GetTools(c *gin.Context) { // UpdateConfigRequest 更新配置请求 type UpdateConfigRequest struct { - AI *config.AIConfig `json:"ai,omitempty"` - OpenAI *config.OpenAIConfig `json:"openai,omitempty"` - Vision *config.VisionConfig `json:"vision,omitempty"` - FOFA *config.FofaConfig `json:"fofa,omitempty"` - ZoomEye *config.SpaceSearchConfig `json:"zoomeye,omitempty"` - Quake *config.SpaceSearchConfig `json:"quake,omitempty"` - Shodan *config.SpaceSearchConfig `json:"shodan,omitempty"` - MCP *config.MCPConfig `json:"mcp,omitempty"` - Tools []ToolEnableStatus `json:"tools,omitempty"` - Agent *AgentConfigUpdate `json:"agent,omitempty"` - Hitl *config.HitlConfig `json:"hitl,omitempty"` - Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"` - Robots *config.RobotsConfig `json:"robots,omitempty"` - MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"` - C2 *config.C2APIUpdate `json:"c2,omitempty"` - Storage *config.StorageConfig `json:"storage,omitempty"` + AgentRuntime *config.AgentRuntimeConfig `json:"agent_runtime,omitempty"` + AI *config.AIConfig `json:"ai,omitempty"` + OpenAI *config.OpenAIConfig `json:"openai,omitempty"` + Vision *config.VisionConfig `json:"vision,omitempty"` + FOFA *config.FofaConfig `json:"fofa,omitempty"` + ZoomEye *config.SpaceSearchConfig `json:"zoomeye,omitempty"` + Quake *config.SpaceSearchConfig `json:"quake,omitempty"` + Shodan *config.SpaceSearchConfig `json:"shodan,omitempty"` + MCP *config.MCPConfig `json:"mcp,omitempty"` + Tools []ToolEnableStatus `json:"tools,omitempty"` + Agent *AgentConfigUpdate `json:"agent,omitempty"` + Hitl *config.HitlConfig `json:"hitl,omitempty"` + Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"` + Robots *config.RobotsConfig `json:"robots,omitempty"` + MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"` + C2 *config.C2APIUpdate `json:"c2,omitempty"` + Storage *config.StorageConfig `json:"storage,omitempty"` } // AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。 @@ -806,6 +807,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if req.AgentRuntime != nil { + engine := strings.ToLower(strings.TrimSpace(req.AgentRuntime.Engine)) + if engine != "pi" && engine != "eino" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent_runtime.engine must be pi or eino"}) + return + } + h.config.AgentRuntime = *req.AgentRuntime + } // 更新OpenAI配置 if req.AI != nil { @@ -1334,27 +1343,6 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) { ctx = openai.WithSessionID(ctx, "connect-test") start := time.Now() - if llm.IsClaudeProvider(req.Provider) { - nativeModel, err := llm.NewClaudeAgenticModel(ctx, *tmpCfg, nil, 5, nil) - if err == nil { - _, err = nativeModel.Generate(ctx, []*schema.AgenticMessage{ - schema.UserAgenticMessage("Hi"), - }) - } - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "error": "连接失败: " + err.Error(), - }) - return - } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "model": tmpCfg.Model, - "latency_ms": time.Since(start).Milliseconds(), - }) - return - } var chatResp struct { ID string `json:"id"` diff --git a/internal/handler/eino_single_agent.go b/internal/handler/eino_single_agent.go index 3701190b..0c192fd1 100644 --- a/internal/handler/eino_single_agent.go +++ b/internal/handler/eino_single_agent.go @@ -247,7 +247,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) { return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments) }) - result, runErr = multiagent.RunEinoSingleChatModelAgent( + result, runErr = multiagent.RunSingleAgent( taskCtxLoop, runCfg, &runCfg.MultiAgent, diff --git a/internal/handler/multi_agent.go b/internal/handler/multi_agent.go index 912de564..46f5c80b 100644 --- a/internal/handler/multi_agent.go +++ b/internal/handler/multi_agent.go @@ -260,7 +260,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) { return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments) }) - result, runErr = multiagent.RunDeepAgent( + result, runErr = multiagent.RunMultiAgent( taskCtxLoop, runCfg, &runCfg.MultiAgent, diff --git a/internal/knowledge/chunk_eino.go b/internal/knowledge/chunk_eino.go deleted file mode 100644 index 6592f350..00000000 --- a/internal/knowledge/chunk_eino.go +++ /dev/null @@ -1,67 +0,0 @@ -package knowledge - -import ( - "context" - "fmt" - "strings" - - "github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown" - "github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive" - "github.com/cloudwego/eino/components/document" - "github.com/pkoukk/tiktoken-go" -) - -func tokenizerLenFunc(embeddingModel string) func(string) int { - fallback := func(s string) int { - r := []rune(s) - if len(r) == 0 { - return 0 - } - return (len(r) + 3) / 4 - } - m := strings.TrimSpace(embeddingModel) - if m == "" { - return fallback - } - tok, err := tiktoken.EncodingForModel(m) - if err != nil { - return fallback - } - return func(s string) int { - return len(tok.Encode(s, nil, nil)) - } -} - -// newKnowledgeSplitter builds an Eino recursive text splitter. LenFunc uses tiktoken for -// embeddingModel when available, else rune/4 approximation. -func newKnowledgeSplitter(chunkSize, overlap int, embeddingModel string) (document.Transformer, error) { - if chunkSize <= 0 { - return nil, fmt.Errorf("chunk size must be positive") - } - if overlap < 0 { - overlap = 0 - } - return recursive.NewSplitter(context.Background(), &recursive.Config{ - ChunkSize: chunkSize, - OverlapSize: overlap, - LenFunc: tokenizerLenFunc(embeddingModel), - Separators: []string{ - "\n\n", "\n## ", "\n### ", "\n#### ", "\n", - "。", "!", "?", ". ", "? ", "! ", - " ", - }, - }) -} - -// newMarkdownHeaderSplitter Eino-ext Markdown 按标题切分(#~####),适合技术/Markdown 知识库。 -func newMarkdownHeaderSplitter(ctx context.Context) (document.Transformer, error) { - return markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{ - Headers: map[string]string{ - "#": "h1", - "##": "h2", - "###": "h3", - "####": "h4", - }, - TrimHeaders: false, - }) -} diff --git a/internal/knowledge/document.go b/internal/knowledge/document.go new file mode 100644 index 00000000..9dd3f9f3 --- /dev/null +++ b/internal/knowledge/document.go @@ -0,0 +1,63 @@ +package knowledge + +import "context" + +// Document is the knowledge-domain record. Retrieval no longer exposes framework types. +type Document struct { + ID string `json:"id"` + Content string `json:"content"` + MetaData map[string]any `json:"metadata,omitempty"` +} + +func (d *Document) WithScore(score float64) *Document { + if d.MetaData == nil { + d.MetaData = map[string]any{} + } + d.MetaData["_score"] = score + return d +} +func (d *Document) Score() float64 { v, _ := d.MetaData["_score"].(float64); return v } + +type RetrievalOptions struct { + TopK *int + DSLInfo map[string]any +} +type RetrievalOption func(*RetrievalOptions) + +func WithTopK(k int) RetrievalOption { return func(o *RetrievalOptions) { o.TopK = &k } } +func WithDSLInfo(d map[string]any) RetrievalOption { + return func(o *RetrievalOptions) { o.DSLInfo = d } +} +func retrievalOptions(opts ...RetrievalOption) *RetrievalOptions { + o := &RetrievalOptions{} + for _, f := range opts { + f(o) + } + return o +} + +type DocumentRetriever interface { + Retrieve(context.Context, string, ...RetrievalOption) ([]*Document, error) +} +type DocumentTransformer interface { + Transform(context.Context, []*Document) ([]*Document, error) +} +type EmbeddingOption struct{} +type TextEmbedder interface { + EmbedStrings(context.Context, []string, ...EmbeddingOption) ([][]float64, error) +} +type IndexOptions struct { + Embedding TextEmbedder + SubIndexes []string +} +type IndexOption func(*IndexOptions) + +func WithEmbedding(e TextEmbedder) IndexOption { return func(o *IndexOptions) { o.Embedding = e } } +func WithSubIndexes(s []string) IndexOption { return func(o *IndexOptions) { o.SubIndexes = s } } +func indexOptions(opts ...IndexOption) *IndexOptions { + o := &IndexOptions{} + for _, f := range opts { + f(o) + } + return o +} diff --git a/internal/knowledge/eino_retrieve_chain.go b/internal/knowledge/eino_retrieve_chain.go deleted file mode 100644 index 81fa4159..00000000 --- a/internal/knowledge/eino_retrieve_chain.go +++ /dev/null @@ -1,24 +0,0 @@ -package knowledge - -import ( - "context" - "fmt" - - "github.com/cloudwego/eino/compose" - "github.com/cloudwego/eino/schema" -) - -// BuildKnowledgeRetrieveChain 编译「查询字符串 → 文档列表」的 Eino Chain(MultiQuery → 向量 → 重排 → 后处理)。 -func BuildKnowledgeRetrieveChain(ctx context.Context, r *Retriever) (compose.Runnable[string, []*schema.Document], error) { - if r == nil { - return nil, fmt.Errorf("retriever is nil") - } - ch := compose.NewChain[string, []*schema.Document]() - ch.AppendRetriever(r.AsEinoRetriever()) - return ch.Compile(ctx) -} - -// CompileRetrieveChain 等价于 [BuildKnowledgeRetrieveChain](ctx, r)。 -func (r *Retriever) CompileRetrieveChain(ctx context.Context) (compose.Runnable[string, []*schema.Document], error) { - return BuildKnowledgeRetrieveChain(ctx, r) -} diff --git a/internal/knowledge/embedder.go b/internal/knowledge/embedder.go index d9ce8afa..caa9f84b 100644 --- a/internal/knowledge/embedder.go +++ b/internal/knowledge/embedder.go @@ -10,15 +10,13 @@ import ( "cyberstrike-ai/internal/config" - einoembedopenai "github.com/cloudwego/eino-ext/components/embedding/openai" - "github.com/cloudwego/eino/components/embedding" "go.uber.org/zap" "golang.org/x/time/rate" ) // Embedder 使用 CloudWeGo Eino 的 OpenAI Embedding 组件,并保留速率限制与重试。 type Embedder struct { - eino embedding.Embedder + eino TextEmbedder config *config.KnowledgeConfig logger *zap.Logger @@ -84,16 +82,7 @@ func NewEmbedder(ctx context.Context, cfg *config.KnowledgeConfig, openAIConfig } httpClient := &http.Client{Timeout: timeout} - inner, err := einoembedopenai.NewEmbedder(ctx, &einoembedopenai.EmbeddingConfig{ - APIKey: apiKey, - BaseURL: baseURL, - ByAzure: false, - Model: model, - HTTPClient: httpClient, - }) - if err != nil { - return nil, fmt.Errorf("eino OpenAI embedder: %w", err) - } + inner := &httpTextEmbedder{client: httpClient, endpoint: baseURL + "/embeddings", key: apiKey, model: model} return &Embedder{ eino: inner, @@ -118,19 +107,23 @@ func (e *Embedder) EmbeddingModelName() string { return "text-embedding-3-small" } -func (e *Embedder) waitRateLimiter() { +func (e *Embedder) waitRateLimiter(ctx context.Context) error { e.mu.Lock() defer e.mu.Unlock() if e.rateLimiter != nil { - ctx := context.Background() - if err := e.rateLimiter.Wait(ctx); err != nil && e.logger != nil { - e.logger.Warn("速率限制器等待失败", zap.Error(err)) + if err := e.rateLimiter.Wait(ctx); err != nil { + return err } } if e.rateLimitDelay > 0 { - time.Sleep(e.rateLimitDelay) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(e.rateLimitDelay): + } } + return ctx.Err() } // EmbedText 单条嵌入(float32,与历史存储格式一致)。 @@ -145,8 +138,8 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error return vecs[0], nil } -// EmbedStrings 批量嵌入,带重试;实现 [embedding.Embedder],可供 Eino Indexer 使用。 -func (e *Embedder) EmbedStrings(ctx context.Context, texts []string, opts ...embedding.Option) ([][]float32, error) { +// EmbedStrings 批量嵌入,带重试;实现 [TextEmbedder],可供 Eino Indexer 使用。 +func (e *Embedder) EmbedStrings(ctx context.Context, texts []string, opts ...EmbeddingOption) ([][]float32, error) { if e == nil || e.eino == nil { return nil, fmt.Errorf("embedder not initialized") } @@ -167,7 +160,9 @@ func (e *Embedder) EmbedStrings(ctx context.Context, texts []string, opts ...emb case <-time.After(wait): } } else { - e.waitRateLimiter() + if err := e.waitRateLimiter(ctx); err != nil { + return nil, err + } } raw, err := e.eino.EmbedStrings(ctx, texts, opts...) @@ -216,12 +211,12 @@ func (e *Embedder) isRetryableError(err error) bool { return false } -// einoFloatEmbedder adapts [][]float32 embedder to Eino's [][]float64 [embedding.Embedder] for Indexer.Store. -type einoFloatEmbedder struct { +// floatEmbedder adapts [][]float32 embedder to Eino's [][]float64 [TextEmbedder] for Indexer.Store. +type floatEmbedder struct { inner *Embedder } -func (w *einoFloatEmbedder) EmbedStrings(ctx context.Context, texts []string, opts ...embedding.Option) ([][]float64, error) { +func (w *floatEmbedder) EmbedStrings(ctx context.Context, texts []string, opts ...EmbeddingOption) ([][]float64, error) { vec32, err := w.inner.EmbedStrings(ctx, texts, opts...) if err != nil { return nil, err @@ -236,16 +231,16 @@ func (w *einoFloatEmbedder) EmbedStrings(ctx context.Context, texts []string, op return out, nil } -func (w *einoFloatEmbedder) GetType() string { +func (w *floatEmbedder) GetType() string { return "CyberStrikeKnowledgeEmbedder" } -func (w *einoFloatEmbedder) IsCallbacksEnabled() bool { +func (w *floatEmbedder) IsCallbacksEnabled() bool { return false } -// EinoEmbeddingComponent returns an [embedding.Embedder] that uses the same retry/rate-limit path +// EmbeddingComponent returns an [TextEmbedder] that uses the same retry/rate-limit path // and produces float64 vectors expected by generic Eino indexer helpers. -func (e *Embedder) EinoEmbeddingComponent() embedding.Embedder { - return &einoFloatEmbedder{inner: e} +func (e *Embedder) EmbeddingComponent() TextEmbedder { + return &floatEmbedder{inner: e} } diff --git a/internal/knowledge/embedding_http.go b/internal/knowledge/embedding_http.go new file mode 100644 index 00000000..ae4d4b91 --- /dev/null +++ b/internal/knowledge/embedding_http.go @@ -0,0 +1,57 @@ +package knowledge + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type httpTextEmbedder struct { + client *http.Client + endpoint, key, model string +} + +func (e *httpTextEmbedder) EmbedStrings(ctx context.Context, texts []string, _ ...EmbeddingOption) ([][]float64, error) { + body, err := json.Marshal(map[string]any{"model": e.model, "input": texts, "encoding_format": "float"}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+e.key) + res, err := e.client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + return nil, fmt.Errorf("embedding HTTP %d: %s", res.StatusCode, b) + } + var output struct { + Data []struct { + Index int `json:"index"` + Embedding []float64 `json:"embedding"` + } `json:"data"` + } + if err = json.NewDecoder(io.LimitReader(res.Body, 64*1024*1024)).Decode(&output); err != nil { + return nil, err + } + if len(output.Data) != len(texts) { + return nil, fmt.Errorf("embedding count mismatch: %d vs %d", len(output.Data), len(texts)) + } + vectors := make([][]float64, len(texts)) + for _, row := range output.Data { + if row.Index < 0 || row.Index >= len(vectors) || vectors[row.Index] != nil || len(row.Embedding) == 0 { + return nil, fmt.Errorf("invalid embedding response index or vector") + } + vectors[row.Index] = row.Embedding + } + return vectors, nil +} diff --git a/internal/knowledge/index_pipeline.go b/internal/knowledge/index_pipeline.go index a9b9a4c4..70f59e3e 100644 --- a/internal/knowledge/index_pipeline.go +++ b/internal/knowledge/index_pipeline.go @@ -7,10 +7,6 @@ import ( "strings" "cyberstrike-ai/internal/config" - - "github.com/cloudwego/eino/components/document" - "github.com/cloudwego/eino/compose" - "github.com/cloudwego/eino/schema" ) // normalizeChunkStrategy returns "recursive" or "markdown_then_recursive". @@ -28,64 +24,59 @@ func normalizeChunkStrategy(s string) string { } } -func buildKnowledgeIndexChain( - ctx context.Context, - indexingCfg *config.IndexingConfig, - db *sql.DB, - recursive document.Transformer, - embeddingModel string, -) (compose.Runnable[[]*schema.Document, []string], error) { - if recursive == nil { - return nil, fmt.Errorf("recursive transformer is nil") - } - if db == nil { - return nil, fmt.Errorf("db is nil") - } - strategy := normalizeChunkStrategy("markdown_then_recursive") - batch := 64 - maxChunks := 0 - if indexingCfg != nil { - strategy = normalizeChunkStrategy(indexingCfg.ChunkStrategy) - if indexingCfg.BatchSize > 0 { - batch = indexingCfg.BatchSize - } - maxChunks = indexingCfg.MaxChunksPerItem - } +type knowledgeIndexChain struct { + splitters []DocumentTransformer + indexer *SQLiteIndexer + maxChunks int +} - si := NewSQLiteIndexer(db, batch, embeddingModel) - ch := compose.NewChain[[]*schema.Document, []string]() +func buildKnowledgeIndexChain(ctx context.Context, cfg *config.IndexingConfig, db *sql.DB, recursive DocumentTransformer, model string) (*knowledgeIndexChain, error) { + if recursive == nil || db == nil { + return nil, fmt.Errorf("knowledge index requires splitter and database") + } + ch := &knowledgeIndexChain{} + batch := 64 + strategy := "markdown_then_recursive" + if cfg != nil { + strategy = normalizeChunkStrategy(cfg.ChunkStrategy) + if cfg.BatchSize > 0 { + batch = cfg.BatchSize + } + ch.maxChunks = cfg.MaxChunksPerItem + } if strategy != "recursive" { md, err := newMarkdownHeaderSplitter(ctx) if err != nil { - return nil, fmt.Errorf("markdown splitter: %w", err) + return nil, err } - ch.AppendDocumentTransformer(md) + ch.splitters = append(ch.splitters, md) } - ch.AppendDocumentTransformer(recursive) - ch.AppendLambda(newChunkEnrichLambda(maxChunks)) - ch.AppendIndexer(si) - return ch.Compile(ctx) + ch.splitters = append(ch.splitters, recursive) + ch.indexer = NewSQLiteIndexer(db, batch, model) + return ch, nil } - -func newChunkEnrichLambda(maxChunks int) *compose.Lambda { - return compose.InvokableLambda(func(ctx context.Context, docs []*schema.Document) ([]*schema.Document, error) { - _ = ctx - out := make([]*schema.Document, 0, len(docs)) - for _, d := range docs { - if d == nil || strings.TrimSpace(d.Content) == "" { - continue - } +func (ch *knowledgeIndexChain) Invoke(ctx context.Context, docs []*Document, opts ...IndexOption) ([]string, error) { + var err error + for _, splitter := range ch.splitters { + docs, err = splitter.Transform(ctx, docs) + if err != nil { + return nil, err + } + } + out := make([]*Document, 0, len(docs)) + for _, d := range docs { + if d != nil && strings.TrimSpace(d.Content) != "" { out = append(out, d) } - if maxChunks > 0 && len(out) > maxChunks { - out = out[:maxChunks] + } + if ch.maxChunks > 0 && len(out) > ch.maxChunks { + out = out[:ch.maxChunks] + } + for i, d := range out { + if d.MetaData == nil { + d.MetaData = map[string]any{} } - for i, d := range out { - if d.MetaData == nil { - d.MetaData = make(map[string]any) - } - d.MetaData[metaKBChunkIndex] = i - } - return out, nil - }) + d.MetaData[metaKBChunkIndex] = i + } + return ch.indexer.Store(ctx, out, opts...) } diff --git a/internal/knowledge/indexer.go b/internal/knowledge/indexer.go index 1c1e7cbe..50218e54 100644 --- a/internal/knowledge/indexer.go +++ b/internal/knowledge/indexer.go @@ -4,17 +4,13 @@ import ( "context" "database/sql" "fmt" + "os" "strings" "sync" "time" "cyberstrike-ai/internal/config" - fileloader "github.com/cloudwego/eino-ext/components/document/loader/file" - "github.com/cloudwego/eino/components/document" - "github.com/cloudwego/eino/components/indexer" - "github.com/cloudwego/eino/compose" - "github.com/cloudwego/eino/schema" "go.uber.org/zap" ) @@ -27,8 +23,7 @@ type Indexer struct { overlap int indexingCfg *config.IndexingConfig - indexChain compose.Runnable[[]*schema.Document, []string] - fileLoader *fileloader.FileLoader + indexChain *knowledgeIndexChain mu sync.RWMutex lastError string @@ -81,16 +76,6 @@ func NewIndexer(ctx context.Context, db *sql.DB, embedder *Embedder, logger *zap return nil, fmt.Errorf("knowledge index chain: %w", err) } - var fl *fileloader.FileLoader - fl, err = fileloader.NewFileLoader(ctx, nil) - if err != nil { - if logger != nil { - logger.Warn("Eino FileLoader 初始化失败,prefer_source_file 将回退数据库正文", zap.Error(err)) - } - fl = nil - err = nil - } - return &Indexer{ db: db, embedder: embedder, @@ -99,7 +84,6 @@ func NewIndexer(ctx context.Context, db *sql.DB, embedder *Embedder, logger *zap overlap: overlap, indexingCfg: indexingCfg, indexChain: chain, - fileLoader: fl, }, nil } @@ -139,36 +123,16 @@ func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error { return fmt.Errorf("获取知识项失败:%w", err) } - if _, err := idx.db.Exec("DELETE FROM knowledge_embeddings WHERE item_id = ?", itemID); err != nil { - return fmt.Errorf("删除旧向量失败:%w", err) - } - body := strings.TrimSpace(content) - if idx.indexingCfg != nil && idx.indexingCfg.PreferSourceFile && strings.TrimSpace(filePath) != "" && idx.fileLoader != nil { - docs, lerr := idx.fileLoader.Load(ctx, document.Source{URI: strings.TrimSpace(filePath)}) - if lerr == nil && len(docs) > 0 { - var b strings.Builder - for i, d := range docs { - if d == nil { - continue - } - if i > 0 { - b.WriteString("\n\n") - } - b.WriteString(d.Content) - } - if s := strings.TrimSpace(b.String()); s != "" { - body = s - } - } else if idx.logger != nil { - idx.logger.Warn("优先源文件读取失败,使用数据库正文", - zap.String("itemId", itemID), - zap.String("path", filePath), - zap.Error(lerr)) + if idx.indexingCfg != nil && idx.indexingCfg.PreferSourceFile && strings.TrimSpace(filePath) != "" { + if b, err := os.ReadFile(strings.TrimSpace(filePath)); err == nil && strings.TrimSpace(string(b)) != "" { + body = string(b) + } else if err != nil && idx.logger != nil { + idx.logger.Warn("源文件读取失败,使用数据库正文", zap.Error(err)) } } - root := &schema.Document{ + root := &Document{ ID: itemID, Content: body, MetaData: map[string]any{ @@ -178,12 +142,12 @@ func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error { }, } - idxOpts := []indexer.Option{indexer.WithEmbedding(idx.embedder.EinoEmbeddingComponent())} + idxOpts := []IndexOption{WithEmbedding(idx.embedder.EmbeddingComponent())} if idx.indexingCfg != nil && len(idx.indexingCfg.SubIndexes) > 0 { - idxOpts = append(idxOpts, indexer.WithSubIndexes(idx.indexingCfg.SubIndexes)) + idxOpts = append(idxOpts, WithSubIndexes(idx.indexingCfg.SubIndexes)) } - ids, err := idx.indexChain.Invoke(ctx, []*schema.Document{root}, compose.WithIndexerOption(idxOpts...)) + ids, err := idx.indexChain.Invoke(ctx, []*Document{root}, idxOpts...) if err != nil { msg := fmt.Sprintf("索引写入失败 (知识项:%s): %v", itemID, err) idx.mu.Lock() diff --git a/internal/knowledge/eino_meta.go b/internal/knowledge/metadata.go similarity index 94% rename from internal/knowledge/eino_meta.go rename to internal/knowledge/metadata.go index 0ee7c41b..71f90e92 100644 --- a/internal/knowledge/eino_meta.go +++ b/internal/knowledge/metadata.go @@ -5,7 +5,7 @@ import ( "strings" ) -// Document metadata keys for Eino schema.Document flowing through the RAG pipeline. +// Document metadata keys for Eino Document flowing through the RAG pipeline. const ( metaKBCategory = "kb_category" metaKBTitle = "kb_title" @@ -14,7 +14,7 @@ const ( metaSimilarity = "similarity" ) -// DSL keys for [VectorEinoRetriever.Retrieve] via [retriever.WithDSLInfo]. +// DSL keys for [VectorRetriever.Retrieve] via [retriever.WithDSLInfo]. const ( DSLRiskType = "risk_type" DSLSimilarityThreshold = "similarity_threshold" diff --git a/internal/knowledge/eino_meta_test.go b/internal/knowledge/metadata_test.go similarity index 100% rename from internal/knowledge/eino_meta_test.go rename to internal/knowledge/metadata_test.go diff --git a/internal/knowledge/native_pipeline_test.go b/internal/knowledge/native_pipeline_test.go new file mode 100644 index 00000000..5936d965 --- /dev/null +++ b/internal/knowledge/native_pipeline_test.go @@ -0,0 +1,60 @@ +package knowledge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "unicode/utf8" +) + +func TestNativeSplitterPreservesUnicodeAndMakesProgressWithLargeOverlap(t *testing.T) { + splitter, err := newKnowledgeSplitter(8, 100, "unknown-tokenizer") + if err != nil { + t.Fatal(err) + } + text := strings.Repeat("中文🙂 evidence。\n", 50) + docs, err := splitter.Transform(context.Background(), []*Document{{ID: "source", Content: text, MetaData: map[string]any{"source": "fixture"}}}) + if err != nil { + t.Fatal(err) + } + if len(docs) < 2 || len(docs) > len([]rune(text)) { + t.Fatalf("unexpected chunk count: %d", len(docs)) + } + for _, doc := range docs { + if !utf8.ValidString(doc.Content) || !strings.Contains(text, doc.Content) || doc.MetaData["source"] != "fixture" { + t.Fatalf("corrupted chunk: %+v", doc) + } + } +} + +func TestNativeEmbeddingUsesResponseIndices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer fixture-key" { + t.Error("missing auth") + } + json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{"index": 1, "embedding": []float64{2, 3}}, {"index": 0, "embedding": []float64{0, 1}}}}) + })) + defer server.Close() + e := &httpTextEmbedder{client: server.Client(), endpoint: server.URL, key: "fixture-key", model: "fixture"} + vec, err := e.EmbedStrings(context.Background(), []string{"a", "b"}) + if err != nil { + t.Fatal(err) + } + if vec[0][0] != 0 || vec[1][0] != 2 { + t.Fatalf("unordered response: %v", vec) + } +} + +func TestNativeEmbeddingRejectsDuplicateIndices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":[{"index":0,"embedding":[1]},{"index":0,"embedding":[2]}]}`)) + })) + defer server.Close() + e := &httpTextEmbedder{client: server.Client(), endpoint: server.URL, model: "fixture"} + if _, err := e.EmbedStrings(context.Background(), []string{"a", "b"}); err == nil { + t.Fatal("duplicate index accepted") + } +} diff --git a/internal/knowledge/rerank_http.go b/internal/knowledge/rerank_http.go index 61e173ed..914f09e2 100644 --- a/internal/knowledge/rerank_http.go +++ b/internal/knowledge/rerank_http.go @@ -12,7 +12,6 @@ import ( "cyberstrike-ai/internal/config" - "github.com/cloudwego/eino/schema" "go.uber.org/zap" ) @@ -56,7 +55,7 @@ func NewHTTPReranker(rc *config.RerankConfig, openAI *config.OpenAIConfig, logge }, nil } -func (r *HTTPReranker) Rerank(ctx context.Context, query string, docs []*schema.Document) ([]*schema.Document, error) { +func (r *HTTPReranker) Rerank(ctx context.Context, query string, docs []*Document) ([]*Document, error) { if r == nil { return docs, nil } @@ -86,7 +85,7 @@ func (r *HTTPReranker) Rerank(ctx context.Context, query string, docs []*schema. if err != nil { return nil, err } - out := make([]*schema.Document, 0, len(order)) + out := make([]*Document, 0, len(order)) for _, idx := range order { if idx < 0 || idx >= len(docs) || docs[idx] == nil { continue diff --git a/internal/knowledge/rerank_http_test.go b/internal/knowledge/rerank_http_test.go index 013ad7d4..fe28cad2 100644 --- a/internal/knowledge/rerank_http_test.go +++ b/internal/knowledge/rerank_http_test.go @@ -8,8 +8,6 @@ import ( "testing" "cyberstrike-ai/internal/config" - - "github.com/cloudwego/eino/schema" ) func TestHTTPReranker_CohereOrder(t *testing.T) { @@ -36,7 +34,7 @@ func TestHTTPReranker_CohereOrder(t *testing.T) { if err != nil { t.Fatal(err) } - docs := []*schema.Document{ + docs := []*Document{ {ID: "a", Content: "alpha"}, {ID: "b", Content: "beta"}, {ID: "c", Content: "gamma"}, @@ -72,7 +70,7 @@ func TestHTTPReranker_DashScopeOrder(t *testing.T) { if err != nil { t.Fatal(err) } - docs := []*schema.Document{{ID: "a", Content: "a"}, {ID: "b", Content: "b"}} + docs := []*Document{{ID: "a", Content: "a"}, {ID: "b", Content: "b"}} out, err := rr.Rerank(context.Background(), "q", docs) if err != nil { t.Fatal(err) diff --git a/internal/knowledge/eino_pipeline_retriever.go b/internal/knowledge/retrieval_pipeline.go similarity index 66% rename from internal/knowledge/eino_pipeline_retriever.go rename to internal/knowledge/retrieval_pipeline.go index 487b439b..585b0a96 100644 --- a/internal/knowledge/eino_pipeline_retriever.go +++ b/internal/knowledge/retrieval_pipeline.go @@ -7,20 +7,16 @@ import ( "cyberstrike-ai/internal/config" - "github.com/cloudwego/eino/callbacks" - "github.com/cloudwego/eino/components" - "github.com/cloudwego/eino/components/retriever" - "github.com/cloudwego/eino/schema" "go.uber.org/zap" ) // knowledgePipelineRetriever: MultiQuery → vector candidates → rerank → post-process. type knowledgePipelineRetriever struct { - inner retriever.Retriever + inner DocumentRetriever base *Retriever } -func newKnowledgePipelineRetriever(inner retriever.Retriever, base *Retriever) *knowledgePipelineRetriever { +func newKnowledgePipelineRetriever(inner DocumentRetriever, base *Retriever) *knowledgePipelineRetriever { if inner == nil || base == nil { return nil } @@ -31,7 +27,7 @@ func (p *knowledgePipelineRetriever) GetType() string { return "KnowledgeRAGPipeline" } -func (p *knowledgePipelineRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) (out []*schema.Document, err error) { +func (p *knowledgePipelineRetriever) Retrieve(ctx context.Context, query string, opts ...RetrievalOption) (out []*Document, err error) { if p == nil || p.inner == nil || p.base == nil { return nil, fmt.Errorf("knowledge pipeline retriever: nil") } @@ -40,7 +36,7 @@ func (p *knowledgePipelineRetriever) Retrieve(ctx context.Context, query string, return nil, fmt.Errorf("查询不能为空") } - ro := retriever.GetCommonOptions(nil, opts...) + ro := retrievalOptions(opts...) finalTopK := p.base.config.TopK if finalTopK <= 0 { finalTopK = 5 @@ -49,16 +45,6 @@ func (p *knowledgePipelineRetriever) Retrieve(ctx context.Context, query string, finalTopK = *ro.TopK } - ctx = callbacks.EnsureRunInfo(ctx, p.GetType(), components.ComponentOfRetriever) - ctx = callbacks.OnStart(ctx, &retriever.CallbackInput{Query: q, TopK: finalTopK, Extra: ro.DSLInfo}) - defer func() { - if err != nil { - _ = callbacks.OnError(ctx, err) - return - } - _ = callbacks.OnEnd(ctx, &retriever.CallbackOutput{Docs: out}) - }() - out, err = p.inner.Retrieve(ctx, q, opts...) if err != nil { return nil, err @@ -93,4 +79,4 @@ func (p *knowledgePipelineRetriever) Retrieve(ctx context.Context, query string, return out, nil } -var _ retriever.Retriever = (*knowledgePipelineRetriever)(nil) +var _ DocumentRetriever = (*knowledgePipelineRetriever)(nil) diff --git a/internal/knowledge/retrieval_postprocess.go b/internal/knowledge/retrieval_postprocess.go index 20e07110..d2a0f3cf 100644 --- a/internal/knowledge/retrieval_postprocess.go +++ b/internal/knowledge/retrieval_postprocess.go @@ -12,7 +12,6 @@ import ( "cyberstrike-ai/internal/config" - "github.com/cloudwego/eino/schema" "github.com/pkoukk/tiktoken-go" ) @@ -21,14 +20,14 @@ const postRetrieveMaxPrefetchCap = 200 // DocumentReranker 精排(HTTP dashscope / Cohere 兼容 API),由 [WireRetrieverPipeline] 注入。 type DocumentReranker interface { - Rerank(ctx context.Context, query string, docs []*schema.Document) ([]*schema.Document, error) + Rerank(ctx context.Context, query string, docs []*Document) ([]*Document, error) } // NopDocumentReranker 占位实现,便于测试或未启用重排时显式注入。 type NopDocumentReranker struct{} // Rerank implements [DocumentReranker] as no-op. -func (NopDocumentReranker) Rerank(_ context.Context, _ string, docs []*schema.Document) ([]*schema.Document, error) { +func (NopDocumentReranker) Rerank(_ context.Context, _ string, docs []*Document) ([]*Document, error) { return docs, nil } @@ -85,7 +84,7 @@ func normalizeContentFingerprintKey(s string) string { return b.String() } -func contentNormKey(d *schema.Document) string { +func contentNormKey(d *Document) string { if d == nil { return "" } @@ -98,12 +97,12 @@ func contentNormKey(d *schema.Document) string { } // dedupeByNormalizedContent 按规范化正文去重,保留向量检索顺序中首次出现的文档(同正文仅保留一条)。 -func dedupeByNormalizedContent(docs []*schema.Document) []*schema.Document { +func dedupeByNormalizedContent(docs []*Document) []*Document { if len(docs) < 2 { return docs } seen := make(map[string]struct{}, len(docs)) - out := make([]*schema.Document, 0, len(docs)) + out := make([]*Document, 0, len(docs)) for _, d := range docs { if d == nil { continue @@ -123,7 +122,7 @@ func dedupeByNormalizedContent(docs []*schema.Document) []*schema.Document { } // truncateDocumentsByBudget 按检索顺序整段保留文档,直至字符数或 token 数(任一启用)超限则停止。 -func truncateDocumentsByBudget(docs []*schema.Document, maxRunes, maxTokens int, tokenModel string) ([]*schema.Document, error) { +func truncateDocumentsByBudget(docs []*Document, maxRunes, maxTokens int, tokenModel string) ([]*Document, error) { if len(docs) == 0 { return docs, nil } @@ -135,7 +134,7 @@ func truncateDocumentsByBudget(docs []*schema.Document, maxRunes, maxTokens int, remRunes := maxRunes remTok := maxTokens - out := make([]*schema.Document, 0, len(docs)) + out := make([]*Document, 0, len(docs)) for _, d := range docs { if d == nil || strings.TrimSpace(d.Content) == "" { @@ -186,7 +185,7 @@ func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int { } // ApplyPostRetrieve 检索后处理:规范化正文去重 → 预算截断 → 最终 TopK(精排已在流水线中完成)。 -func ApplyPostRetrieve(docs []*schema.Document, po *config.PostRetrieveConfig, tokenModel string, finalTopK int) ([]*schema.Document, error) { +func ApplyPostRetrieve(docs []*Document, po *config.PostRetrieveConfig, tokenModel string, finalTopK int) ([]*Document, error) { if finalTopK < 1 { finalTopK = 5 } diff --git a/internal/knowledge/retrieval_postprocess_test.go b/internal/knowledge/retrieval_postprocess_test.go index 889d5c62..6f39be82 100644 --- a/internal/knowledge/retrieval_postprocess_test.go +++ b/internal/knowledge/retrieval_postprocess_test.go @@ -4,12 +4,10 @@ import ( "testing" "cyberstrike-ai/internal/config" - - "github.com/cloudwego/eino/schema" ) -func doc(id, content string, score float64) *schema.Document { - d := &schema.Document{ID: id, Content: content, MetaData: map[string]any{metaKBItemID: "it1"}} +func doc(id, content string, score float64) *Document { + d := &Document{ID: id, Content: content, MetaData: map[string]any{metaKBItemID: "it1"}} d.WithScore(score) return d } @@ -18,7 +16,7 @@ func TestDedupeByNormalizedContent(t *testing.T) { a := doc("1", "hello world", 0.9) b := doc("2", "hello world", 0.8) c := doc("3", "other", 0.7) - out := dedupeByNormalizedContent([]*schema.Document{a, b, c}) + out := dedupeByNormalizedContent([]*Document{a, b, c}) if len(out) != 2 { t.Fatalf("len=%d want 2", len(out)) } @@ -44,7 +42,7 @@ func TestApplyPostRetrieveTruncateAndTopK(t *testing.T) { d2 := doc("2", "cd", 0.8) d3 := doc("3", "ef", 0.7) po := &config.PostRetrieveConfig{MaxContextChars: 3} - out, err := ApplyPostRetrieve([]*schema.Document{d1, d2, d3}, po, "gpt-4", 5) + out, err := ApplyPostRetrieve([]*Document{d1, d2, d3}, po, "gpt-4", 5) if err != nil { t.Fatal(err) } @@ -52,7 +50,7 @@ func TestApplyPostRetrieveTruncateAndTopK(t *testing.T) { t.Fatalf("got %#v", out) } - out2, err := ApplyPostRetrieve([]*schema.Document{d1, d2, d3}, nil, "gpt-4", 2) + out2, err := ApplyPostRetrieve([]*Document{d1, d2, d3}, nil, "gpt-4", 2) if err != nil { t.Fatal(err) } diff --git a/internal/knowledge/retrieve_chain.go b/internal/knowledge/retrieve_chain.go new file mode 100644 index 00000000..328741a4 --- /dev/null +++ b/internal/knowledge/retrieve_chain.go @@ -0,0 +1,21 @@ +package knowledge + +import ( + "context" + "fmt" +) + +type RetrieveChain struct{ retriever *Retriever } + +func BuildKnowledgeRetrieveChain(ctx context.Context, r *Retriever) (*RetrieveChain, error) { + if r == nil { + return nil, fmt.Errorf("retriever is nil") + } + return &RetrieveChain{r}, nil +} +func (r *Retriever) CompileRetrieveChain(ctx context.Context) (*RetrieveChain, error) { + return BuildKnowledgeRetrieveChain(ctx, r) +} +func (c *RetrieveChain) Invoke(ctx context.Context, query string, opts ...RetrievalOption) ([]*Document, error) { + return c.retriever.activeRetriever().Retrieve(ctx, query, opts...) +} diff --git a/internal/knowledge/eino_retrieve_chain_test.go b/internal/knowledge/retrieve_chain_test.go similarity index 100% rename from internal/knowledge/eino_retrieve_chain_test.go rename to internal/knowledge/retrieve_chain_test.go diff --git a/internal/knowledge/retriever.go b/internal/knowledge/retriever.go index c75e8a13..c685c5c7 100644 --- a/internal/knowledge/retriever.go +++ b/internal/knowledge/retriever.go @@ -12,13 +12,11 @@ import ( "cyberstrike-ai/internal/config" - "github.com/cloudwego/eino/components/retriever" - "github.com/cloudwego/eino/schema" "go.uber.org/zap" ) // Retriever 检索器:SQLite 存向量 + Eino 嵌入,**纯向量检索**(余弦相似度、TopK、阈值), -// 实现语义与 [retriever.Retriever] 适配层 [VectorEinoRetriever] 一致。 +// 实现语义与 [DocumentRetriever] 适配层 [VectorRetriever] 一致。 type Retriever struct { db *sql.DB embedder *Embedder @@ -28,7 +26,7 @@ type Retriever struct { rerankMu sync.RWMutex reranker DocumentReranker - pipeline retriever.Retriever + pipeline DocumentRetriever wireOpenAI *config.OpenAIConfig } @@ -122,18 +120,18 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva if q == "" { return nil, fmt.Errorf("查询不能为空") } - opts := r.einoRetrieverOptions(req) - docs, err := r.activeEinoRetriever().Retrieve(ctx, q, opts...) + opts := r.retrievalOptionsForRequest(req) + docs, err := r.activeRetriever().Retrieve(ctx, q, opts...) if err != nil { return nil, err } return documentsToRetrievalResults(docs) } -func (r *Retriever) einoRetrieverOptions(req *SearchRequest) []retriever.Option { - var opts []retriever.Option +func (r *Retriever) retrievalOptionsForRequest(req *SearchRequest) []RetrievalOption { + var opts []RetrievalOption if req.TopK > 0 { - opts = append(opts, retriever.WithTopK(req.TopK)) + opts = append(opts, WithTopK(req.TopK)) } dsl := map[string]any{} if strings.TrimSpace(req.RiskType) != "" { @@ -146,26 +144,26 @@ func (r *Retriever) einoRetrieverOptions(req *SearchRequest) []retriever.Option dsl[DSLSubIndexFilter] = strings.TrimSpace(req.SubIndexFilter) } if len(dsl) > 0 { - opts = append(opts, retriever.WithDSLInfo(dsl)) + opts = append(opts, WithDSLInfo(dsl)) } return opts } -// EinoRetrieve 直接返回 [schema.Document],供 Eino Graph / Chain 使用。 -func (r *Retriever) EinoRetrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) { - return r.activeEinoRetriever().Retrieve(ctx, query, opts...) +// RetrieveDocuments 直接返回 [Document],供 Eino Graph / Chain 使用。 +func (r *Retriever) RetrieveDocuments(ctx context.Context, query string, opts ...RetrievalOption) ([]*Document, error) { + return r.activeRetriever().Retrieve(ctx, query, opts...) } -func (r *Retriever) activeEinoRetriever() retriever.Retriever { +func (r *Retriever) activeRetriever() DocumentRetriever { if r != nil && r.pipeline != nil { return r.pipeline } - return NewVectorEinoRetriever(r) + return NewVectorRetriever(r) } -// AsEinoRetriever 将知识库检索流水线暴露为 Eino [retriever.Retriever]。 -func (r *Retriever) AsEinoRetriever() retriever.Retriever { - return r.activeEinoRetriever() +// AsRetriever 将知识库检索流水线暴露为 Eino [DocumentRetriever]。 +func (r *Retriever) AsRetriever() DocumentRetriever { + return r.activeRetriever() } func (r *Retriever) knowledgeEmbeddingSelectSQL(riskType, subIndexFilter string) (string, []interface{}) { diff --git a/internal/knowledge/splitter.go b/internal/knowledge/splitter.go new file mode 100644 index 00000000..a3f8b69f --- /dev/null +++ b/internal/knowledge/splitter.go @@ -0,0 +1,123 @@ +package knowledge + +import ( + "context" + "fmt" + "strings" + + "github.com/pkoukk/tiktoken-go" +) + +func tokenizerLenFunc(embeddingModel string) func(string) int { + fallback := func(s string) int { + r := []rune(s) + if len(r) == 0 { + return 0 + } + return (len(r) + 3) / 4 + } + m := strings.TrimSpace(embeddingModel) + if m == "" { + return fallback + } + tok, err := tiktoken.EncodingForModel(m) + if err != nil { + return fallback + } + return func(s string) int { + return len(tok.Encode(s, nil, nil)) + } +} + +type nativeSplitter struct { + size, overlap int + length func(string) int + markdown bool +} + +func newKnowledgeSplitter(size, overlap int, model string) (DocumentTransformer, error) { + if size <= 0 { + return nil, fmt.Errorf("chunk size must be positive") + } + if overlap < 0 { + overlap = 0 + } + if overlap >= size { + overlap = size / 4 + } + return &nativeSplitter{size: size, overlap: overlap, length: tokenizerLenFunc(model)}, nil +} +func newMarkdownHeaderSplitter(context.Context) (DocumentTransformer, error) { + return &nativeSplitter{markdown: true}, nil +} +func cloneDocument(d *Document, content string) *Document { + meta := map[string]any{} + for k, v := range d.MetaData { + meta[k] = v + } + return &Document{ID: d.ID, Content: content, MetaData: meta} +} +func (s *nativeSplitter) Transform(ctx context.Context, docs []*Document) ([]*Document, error) { + var out []*Document + for _, d := range docs { + if d == nil { + continue + } + if err := ctx.Err(); err != nil { + return nil, err + } + if s.markdown { + var section strings.Builder + for _, line := range strings.SplitAfter(d.Content, "\n") { + trimmed := strings.TrimLeft(line, " ") + heading := strings.HasPrefix(trimmed, "# ") || strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "### ") || strings.HasPrefix(trimmed, "#### ") + if heading && section.Len() > 0 { + out = append(out, cloneDocument(d, section.String())) + section.Reset() + } + section.WriteString(line) + } + if section.Len() > 0 { + out = append(out, cloneDocument(d, section.String())) + } + continue + } + runes := []rune(d.Content) + for start := 0; start < len(runes); { + if err := ctx.Err(); err != nil { + return nil, err + } + low, high := start+1, len(runes) + for low < high { + mid := (low + high + 1) / 2 + if s.length(string(runes[start:mid])) <= s.size { + low = mid + } else { + high = mid - 1 + } + } + end := low + if end < len(runes) { + for j := end - 1; j > start+(end-start)/2; j-- { + if strings.ContainsRune("\n。!? ", runes[j]) { + end = j + 1 + break + } + } + } + out = append(out, cloneDocument(d, string(runes[start:end]))) + if end == len(runes) { + break + } + next := end + for next > start+1 && s.length(string(runes[next-1:end])) <= s.overlap { + next-- + } + if next <= start { + next = end + } + start = next + } + } + return out, nil +} diff --git a/internal/knowledge/eino_sqlite_indexer.go b/internal/knowledge/sqlite_indexer.go similarity index 85% rename from internal/knowledge/eino_sqlite_indexer.go rename to internal/knowledge/sqlite_indexer.go index a0bbdcdc..7ce50af4 100644 --- a/internal/knowledge/eino_sqlite_indexer.go +++ b/internal/knowledge/sqlite_indexer.go @@ -7,10 +7,6 @@ import ( "fmt" "strings" - "github.com/cloudwego/eino/callbacks" - "github.com/cloudwego/eino/components" - "github.com/cloudwego/eino/components/indexer" - "github.com/cloudwego/eino/schema" "github.com/google/uuid" ) @@ -35,8 +31,8 @@ func (s *SQLiteIndexer) GetType() string { // Store embeds documents and inserts rows. Each doc must carry MetaData: // kb_item_id, kb_category, kb_title, kb_chunk_index (int). Content is chunk text only. -func (s *SQLiteIndexer) Store(ctx context.Context, docs []*schema.Document, opts ...indexer.Option) (ids []string, err error) { - options := indexer.GetCommonOptions(nil, opts...) +func (s *SQLiteIndexer) Store(ctx context.Context, docs []*Document, opts ...IndexOption) (ids []string, err error) { + options := indexOptions(opts...) if options.Embedding == nil { return nil, fmt.Errorf("sqlite indexer: embedding is required") } @@ -44,16 +40,6 @@ func (s *SQLiteIndexer) Store(ctx context.Context, docs []*schema.Document, opts return nil, nil } - ctx = callbacks.EnsureRunInfo(ctx, s.GetType(), components.ComponentOfIndexer) - ctx = callbacks.OnStart(ctx, &indexer.CallbackInput{Docs: docs}) - defer func() { - if err != nil { - _ = callbacks.OnError(ctx, err) - return - } - _ = callbacks.OnEnd(ctx, &indexer.CallbackOutput{IDs: ids}) - }() - subIdxStr := strings.Join(options.SubIndexes, ",") texts := make([]string, len(docs)) @@ -99,6 +85,19 @@ func (s *SQLiteIndexer) Store(ctx context.Context, docs []*schema.Document, opts } defer tx.Rollback() + items := map[string]bool{} + for _, d := range docs { + id, err := RequireMetaString(d.MetaData, metaKBItemID) + if err != nil { + return nil, err + } + items[id] = true + } + for id := range items { + if _, err := tx.ExecContext(ctx, "DELETE FROM knowledge_embeddings WHERE item_id = ?", id); err != nil { + return nil, err + } + } ids = make([]string, 0, len(docs)) for i, d := range docs { chunkID := uuid.New().String() @@ -138,5 +137,3 @@ func (s *SQLiteIndexer) Store(ctx context.Context, docs []*schema.Document, opts } return ids, nil } - -var _ indexer.Indexer = (*SQLiteIndexer)(nil) diff --git a/internal/knowledge/tool.go b/internal/knowledge/tool.go index ffceb7e2..72996b73 100644 --- a/internal/knowledge/tool.go +++ b/internal/knowledge/tool.go @@ -123,7 +123,7 @@ func RegisterKnowledgeTool( zap.String("riskType", riskType), ) - // 检索统一走 Retriever.Search → VectorEinoRetriever(Eino retriever 语义)。 + // 检索统一走 Retriever.Search → VectorRetriever(Eino retriever 语义)。 searchReq := &SearchRequest{ Query: query, RiskType: riskType, diff --git a/internal/knowledge/eino_retriever_adapter.go b/internal/knowledge/vector_retriever.go similarity index 67% rename from internal/knowledge/eino_retriever_adapter.go rename to internal/knowledge/vector_retriever.go index 712b4734..3ca5f603 100644 --- a/internal/knowledge/eino_retriever_adapter.go +++ b/internal/knowledge/vector_retriever.go @@ -6,43 +6,38 @@ import ( "strings" "cyberstrike-ai/internal/config" - - "github.com/cloudwego/eino/callbacks" - "github.com/cloudwego/eino/components" - "github.com/cloudwego/eino/components/retriever" - "github.com/cloudwego/eino/schema" ) -// VectorEinoRetriever implements [retriever.Retriever] on top of SQLite-stored embeddings + cosine similarity. +// VectorRetriever implements [DocumentRetriever] on top of SQLite-stored embeddings + cosine similarity. // It returns prefetch-sized vector candidates only; rerank and post-process run in [knowledgePipelineRetriever]. -type VectorEinoRetriever struct { +type VectorRetriever struct { inner *Retriever } -// NewVectorEinoRetriever wraps r for Eino compose / tooling. -func NewVectorEinoRetriever(r *Retriever) *VectorEinoRetriever { +// NewVectorRetriever wraps r for Eino compose / tooling. +func NewVectorRetriever(r *Retriever) *VectorRetriever { if r == nil { return nil } - return &VectorEinoRetriever{inner: r} + return &VectorRetriever{inner: r} } // GetType identifies this retriever for Eino callbacks. -func (h *VectorEinoRetriever) GetType() string { +func (h *VectorRetriever) GetType() string { return "SQLiteVectorKnowledgeRetriever" } -// Retrieve runs vector search and returns [schema.Document] rows. -func (h *VectorEinoRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) (out []*schema.Document, err error) { +// Retrieve runs vector search and returns [Document] rows. +func (h *VectorRetriever) Retrieve(ctx context.Context, query string, opts ...RetrievalOption) (out []*Document, err error) { if h == nil || h.inner == nil { - return nil, fmt.Errorf("VectorEinoRetriever: nil retriever") + return nil, fmt.Errorf("VectorRetriever: nil retriever") } q := strings.TrimSpace(query) if q == "" { return nil, fmt.Errorf("查询不能为空") } - ro := retriever.GetCommonOptions(nil, opts...) + ro := retrievalOptions(opts...) cfg := h.inner.config req := &SearchRequest{Query: q} @@ -88,23 +83,6 @@ func (h *VectorEinoRetriever) Retrieve(ctx context.Context, query string, opts . searchReq := *req searchReq.TopK = fetchK - ctx = callbacks.EnsureRunInfo(ctx, h.GetType(), components.ComponentOfRetriever) - th := req.Threshold - st := &th - ctx = callbacks.OnStart(ctx, &retriever.CallbackInput{ - Query: q, - TopK: finalTopK, - ScoreThreshold: st, - Extra: ro.DSLInfo, - }) - defer func() { - if err != nil { - _ = callbacks.OnError(ctx, err) - return - } - _ = callbacks.OnEnd(ctx, &retriever.CallbackOutput{Docs: out}) - }() - results, err := h.inner.vectorSearch(ctx, &searchReq) if err != nil { return nil, err @@ -113,13 +91,13 @@ func (h *VectorEinoRetriever) Retrieve(ctx context.Context, query string, opts . return out, nil } -func retrievalResultsToDocuments(results []*RetrievalResult) []*schema.Document { - out := make([]*schema.Document, 0, len(results)) +func retrievalResultsToDocuments(results []*RetrievalResult) []*Document { + out := make([]*Document, 0, len(results)) for _, res := range results { if res == nil || res.Chunk == nil || res.Item == nil { continue } - d := &schema.Document{ + d := &Document{ ID: res.Chunk.ID, Content: res.Chunk.ChunkText, MetaData: map[string]any{ @@ -136,7 +114,7 @@ func retrievalResultsToDocuments(results []*RetrievalResult) []*schema.Document return out } -func documentsToRetrievalResults(docs []*schema.Document) ([]*RetrievalResult, error) { +func documentsToRetrievalResults(docs []*Document) ([]*RetrievalResult, error) { out := make([]*RetrievalResult, 0, len(docs)) for i, d := range docs { if d == nil { @@ -170,4 +148,4 @@ func documentsToRetrievalResults(docs []*schema.Document) ([]*RetrievalResult, e return out, nil } -var _ retriever.Retriever = (*VectorEinoRetriever)(nil) +var _ DocumentRetriever = (*VectorRetriever)(nil) diff --git a/internal/knowledge/wire_retriever.go b/internal/knowledge/wire_retriever.go index 5cca7258..174b1917 100644 --- a/internal/knowledge/wire_retriever.go +++ b/internal/knowledge/wire_retriever.go @@ -2,94 +2,107 @@ package knowledge import ( "context" + "encoding/json" "fmt" "net/http" + "sort" "strings" "time" "cyberstrike-ai/internal/config" - "cyberstrike-ai/internal/llm" "cyberstrike-ai/internal/openai" - - einoopenai "github.com/cloudwego/eino-ext/components/model/openai" - "github.com/cloudwego/eino/components/model" - "github.com/cloudwego/eino/flow/retriever/multiquery" "go.uber.org/zap" ) -// WireRetrieverPipeline builds Eino MultiQuery + HTTP rerank + post-process pipeline on r. -// Call once after NewRetriever; UpdateConfig re-invokes when wireOpenAI is set. -func WireRetrieverPipeline(ctx context.Context, r *Retriever, openAI *config.OpenAIConfig) error { - if r == nil { - return fmt.Errorf("retriever is nil") +// WireRetrieverPipeline composes query expansion, vector search, reranking and context budgeting. +func WireRetrieverPipeline(ctx context.Context, r *Retriever, oa *config.OpenAIConfig) error { + if r == nil || oa == nil || r.config == nil { + return fmt.Errorf("knowledge retrieval configuration is incomplete") } - if openAI == nil { - return fmt.Errorf("openai config is nil") - } - if r.config == nil { - return fmt.Errorf("retrieval config is nil") - } - r.wireOpenAI = openAI - - baseHTTPClient := &http.Client{Timeout: 120 * time.Second} - var rewriteLLM model.ChatModel - if llm.IsClaudeProvider(openAI.Provider) { - nativeModel, err := llm.NewClaudeAgenticModel( - ctx, - *openAI, - baseHTTPClient, - openAI.MaxCompletionTokensEffective(), - nil, - ) - if err != nil { - return fmt.Errorf("multi_query native Claude rewrite model: %w", err) - } - rewriteLLM = llm.NewAgenticChatModelAdapter(nativeModel) - } else { - httpClient := openai.NewEinoHTTPClient(openAI, baseHTTPClient) - maxCompletionTokens := openAI.MaxCompletionTokensEffective() - chatCfg := &einoopenai.ChatModelConfig{ - APIKey: strings.TrimSpace(openAI.APIKey), - BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"), - Model: strings.TrimSpace(openAI.Model), - HTTPClient: httpClient, - MaxCompletionTokens: &maxCompletionTokens, - } - if chatCfg.Model == "" { - chatCfg.Model = "gpt-4o" - } - var err error - rewriteLLM, err = einoopenai.NewChatModel(ctx, chatCfg) - if err != nil { - return fmt.Errorf("multi_query rewrite model: %w", err) - } - } - - reranker, err := NewHTTPReranker(&r.config.Rerank, openAI, r.logger) + r.wireOpenAI = oa + reranker, err := NewHTTPReranker(&r.config.Rerank, oa, r.logger) if err != nil { - return fmt.Errorf("reranker: %w", err) + return err } r.SetDocumentReranker(reranker) - - vec := NewVectorEinoRetriever(r) - mq, err := multiquery.NewRetriever(ctx, &multiquery.Config{ - RewriteLLM: rewriteLLM, - MaxQueriesNum: r.config.MultiQuery.MaxQueriesEffective(), - OrigRetriever: vec, - }) - if err != nil { - return fmt.Errorf("multi_query: %w", err) + logger := r.logger + if logger == nil { + logger = zap.NewNop() } - + mq := &nativeMultiQuery{inner: NewVectorRetriever(r), max: r.config.MultiQuery.MaxQueriesEffective(), client: openai.NewClient(oa, &http.Client{Timeout: 120 * time.Second}, logger), model: oa.Model, logger: logger} r.pipeline = newKnowledgePipelineRetriever(mq, r) - if r.logger != nil { - provider := r.config.Rerank.ProviderEffective(strings.TrimSpace(openAI.BaseURL)) - r.logger.Info("知识库检索流水线已启用", - zap.String("pipeline", "MultiQuery→Vector→Rerank→PostRetrieve"), - zap.Int("multi_query_max", r.config.MultiQuery.MaxQueriesEffective()), - zap.String("rerank_provider", provider), - zap.String("rerank_model", r.config.Rerank.ModelEffective(provider)), - ) - } return nil } + +type nativeMultiQuery struct { + inner DocumentRetriever + max int + client *openai.Client + model string + logger *zap.Logger +} + +func (q *nativeMultiQuery) Retrieve(ctx context.Context, query string, opts ...RetrievalOption) ([]*Document, error) { + queries := []string{query} + if q.max > 1 { + payload := map[string]any{"model": q.model, "messages": []map[string]string{{"role": "system", "content": fmt.Sprintf("Return only a JSON array containing at most %d alternative search queries for the user's knowledge search. Preserve technical identifiers and the user's language.", q.max-1)}, {"role": "user", "content": query}}} + var answer struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := q.client.ChatCompletion(ctx, payload, &answer); err == nil && len(answer.Choices) > 0 { + text := strings.TrimSpace(answer.Choices[0].Message.Content) + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + var generated []string + if json.Unmarshal([]byte(strings.TrimSpace(text)), &generated) == nil { + seen := map[string]bool{query: true} + for _, v := range generated { + v = strings.TrimSpace(v) + if v != "" && !seen[v] { + queries = append(queries, v) + seen[v] = true + } + if len(queries) >= q.max { + break + } + } + } + } else if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + q.logger.Warn("查询扩展失败,使用原始查询", zap.Error(err)) + } + } + scores := map[string]float64{} + docs := map[string]*Document{} + var order []string + for _, query := range queries { + rows, err := q.inner.Retrieve(ctx, query, opts...) + if err != nil { + return nil, err + } + for rank, d := range rows { + key := d.ID + if key == "" { + key = d.Content + } + if _, ok := docs[key]; !ok { + docs[key] = d + order = append(order, key) + } + scores[key] += 1 / float64(60+rank+1) + } + } + sort.SliceStable(order, func(i, j int) bool { return scores[order[i]] > scores[order[j]] }) + out := make([]*Document, 0, len(order)) + for _, key := range order { + out = append(out, docs[key]) + } + return out, nil +} diff --git a/internal/multiagent/pi_integration_test.go b/internal/multiagent/pi_integration_test.go new file mode 100644 index 00000000..5dfa1309 --- /dev/null +++ b/internal/multiagent/pi_integration_test.go @@ -0,0 +1,164 @@ +package multiagent + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "cyberstrike-ai/internal/agent" + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/mcp" + "go.uber.org/zap" +) + +func piTestConfig(t *testing.T) *config.Config { + t.Helper() + if _, err := exec.LookPath("node"); err != nil { + t.Skip("Node not installed") + } + worker, err := filepath.Abs("../../runtime/pi/dist/worker.js") + if err != nil { + t.Fatal(err) + } + if _, err = os.Stat(worker); err != nil { + t.Skip("build runtime/pi before SDK integration tests") + } + cfg := &config.Config{AgentRuntime: config.AgentRuntimeConfig{Engine: "pi", WorkerPath: worker, SessionDir: t.TempDir()}, OpenAI: config.OpenAIConfig{Provider: "openai", APIKey: "fixture-key", Model: "fixture", MaxTotalTokens: 32000, MaxCompletionTokens: 1024}} + cfg.Agent.MaxIterations = 8 + cfg.MultiAgent.EinoSkills.Disable = true + return cfg +} + +func piReply(w http.ResponseWriter, tool, id string, args map[string]any, text string) { + w.Header().Set("Content-Type", "text/event-stream") + delta := map[string]any{"role": "assistant"} + reason := "stop" + if tool != "" { + body, _ := json.Marshal(args) + delta["tool_calls"] = []any{map[string]any{"index": 0, "id": id, "type": "function", "function": map[string]any{"name": tool, "arguments": string(body)}}} + reason = "tool_calls" + } else { + delta["content"] = text + } + for _, choice := range []map[string]any{{"index": 0, "delta": delta, "finish_reason": nil}, {"index": 0, "delta": map[string]any{}, "finish_reason": reason}} { + b, _ := json.Marshal(map[string]any{"id": "fixture", "object": "chat.completion.chunk", "model": "fixture", "choices": []any{choice}, "usage": map[string]any{"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30}}) + fmt.Fprintf(w, "data: %s\n\n", b) + } + fmt.Fprint(w, "data: [DONE]\n\n") +} + +func piTestAgent(cfg *config.Config, handler mcp.ToolHandler) *agent.Agent { + srv := mcp.NewServer(zap.NewNop()) + schema := map[string]any{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}, "required": []string{"text"}} + srv.RegisterTool(mcp.Tool{Name: "echo", Description: "Echo fixture", InputSchema: schema}, handler) + srv.RegisterTool(mcp.Tool{Name: "secret", Description: "Excluded fixture tool", InputSchema: schema}, func(context.Context, map[string]interface{}) (*mcp.ToolResult, error) { + return nil, fmt.Errorf("secret must never execute") + }) + return agent.NewAgent(&cfg.OpenAI, &cfg.Agent, srv, nil, zap.NewNop(), 8) +} + +func TestPiSinglePreservesHITLAndExecutionEvidence(t *testing.T) { + cfg := piTestConfig(t) + var calls atomic.Int32 + var executed atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if calls.Add(1) == 1 { + piReply(w, "echo", "echo-1", map[string]any{"text": "original"}, "") + } else { + raw, _ := json.Marshal(body) + if !strings.Contains(string(raw), "approved") { + t.Error("approved result missing from next model request") + } + piReply(w, "", "", nil, "verified") + } + })) + defer server.Close() + cfg.OpenAI.BaseURL = server.URL + "/v1" + ag := piTestAgent(cfg, func(_ context.Context, args map[string]interface{}) (*mcp.ToolResult, error) { + executed.Add(1) + if args["text"] != "approved" { + t.Errorf("unreviewed args: %v", args) + } + return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "approved"}}}, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + ctx = WithHITLToolInterceptor(ctx, func(context.Context, string, string) (string, error) { return `{"text":"approved"}`, nil }) + res, err := RunSingleAgent(ctx, cfg, &cfg.MultiAgent, ag, nil, zap.NewNop(), "single-fixture", "", "echo", nil, []string{"echo"}, nil, nil, "") + if err != nil { + t.Fatal(err) + } + if res.Response != "verified" || executed.Load() != 1 || len(res.MCPExecutionIDs) != 1 { + t.Fatalf("result=%+v executions=%d", res, executed.Load()) + } +} + +func TestPiMultiUsesNativeChildAndRestrictsItsTools(t *testing.T) { + cfg := piTestConfig(t) + cfg.MultiAgent.SubAgents = []config.MultiAgentSubConfig{{ID: "specialist", Description: "Fixture specialist", Instruction: "Return observed evidence"}} + var calls atomic.Int32 + var executions atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + Messages []map[string]any `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + for _, tool := range body.Tools { + if tool.Function.Name == "secret" { + t.Error("child expanded the parent tool catalog") + } + } + switch calls.Add(1) { + case 1: + piReply(w, "task", "task-1", map[string]any{"subagent_type": "specialist", "description": "echo evidence"}, "") + case 2: + raw, _ := json.Marshal(body.Messages) + if !strings.Contains(string(raw), "Return observed evidence") { + t.Error("child did not receive its specialist instructions") + } + piReply(w, "echo", "echo-2", map[string]any{"text": "evidence"}, "") + case 3: + piReply(w, "", "", nil, "child evidence") + case 4: + piReply(w, "exit", "exit-1", map[string]any{"final_result": "verified multi"}, "") + default: + t.Error("unexpected request after exit") + piReply(w, "", "", nil, "unexpected") + } + })) + defer server.Close() + cfg.OpenAI.BaseURL = server.URL + "/v1" + ag := piTestAgent(cfg, func(context.Context, map[string]interface{}) (*mcp.ToolResult, error) { + executions.Add(1) + return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "evidence"}}}, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + res, err := RunMultiAgent(ctx, cfg, &cfg.MultiAgent, ag, nil, zap.NewNop(), "multi-fixture", "", "delegate", nil, []string{"echo"}, nil, "", "deep", nil, "") + if err != nil { + t.Fatal(err) + } + if res.Response != "verified multi" || calls.Load() != 4 || executions.Load() != 1 || len(res.MCPExecutionIDs) != 1 { + t.Fatalf("result=%+v calls=%d executions=%d", res, calls.Load(), executions.Load()) + } +} diff --git a/internal/multiagent/pi_local_tools.go b/internal/multiagent/pi_local_tools.go new file mode 100644 index 00000000..620cfe65 --- /dev/null +++ b/internal/multiagent/pi_local_tools.go @@ -0,0 +1,342 @@ +package multiagent + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "cyberstrike-ai/internal/agentruntime" + "cyberstrike-ai/internal/security" + "cyberstrike-ai/internal/skillpackage" + "cyberstrike-ai/internal/tooloutput" +) + +// piLocalCatalog is explicit host registration: Pi never receives default host filesystem tools. +func (r *piRun) piLocalCatalog(ctx context.Context) ([]agentruntime.Tool, map[string]bool, string, error) { + var tools []agentruntime.Tool + names := map[string]bool{} + var catalog strings.Builder + if r.cfg.MultiAgent.EinoSkills.Disable { + return tools, names, "", nil + } + str := map[string]any{"type": "string"} + if r.cfg.MultiAgent.EinoMiddleware.PlantaskEnable && r.cfg.SkillsDir != "" { + for _, t := range piPlanTools() { + tools = append(tools, t) + names[t.Name] = true + } + } + if root := strings.TrimSpace(r.cfg.SkillsDir); root != "" { + skills, err := skillpackage.ListSkillSummaries(root) + if err != nil && !os.IsNotExist(err) { + return nil, nil, "", err + } + for _, s := range skills { + fmt.Fprintf(&catalog, "- %s: %s\n", s.ID, s.Description) + } + if len(skills) > 0 { + name := r.cfg.MultiAgent.EinoSkills.SkillToolName + if name == "" { + name = "skill" + } + tools = append(tools, agentruntime.Tool{Name: name, Description: "Load the instructions and resource paths of an available skill", Parameters: piToolSchema(map[string]any{"name": str}, "name")}) + names[name] = true + } + } + if r.cfg.MultiAgent.EinoSkills.EinoSkillFilesystemToolsEffective() && !localToolPermissionDenied(ctx, "execute") { + for _, t := range []agentruntime.Tool{ + {Name: "read_file", Description: "Read a text file, optionally selecting a 1-based line offset and line limit", Parameters: piToolSchema(map[string]any{"path": str, "offset": map[string]any{"type": "integer", "minimum": 1}, "limit": map[string]any{"type": "integer", "minimum": 1}}, "path")}, + {Name: "write_file", Description: "Write UTF-8 text to a file", Parameters: piToolSchema(map[string]any{"path": str, "content": str}, "path", "content")}, + {Name: "edit_file", Description: "Replace one exact occurrence in a text file; ambiguous matches fail", Parameters: piToolSchema(map[string]any{"path": str, "old_text": str, "new_text": str}, "path", "old_text", "new_text")}, + {Name: "ls", Description: "List directory entries", Parameters: piToolSchema(map[string]any{"path": str}, "path")}, + {Name: "glob", Description: "Find files recursively by a filename glob under a directory", Parameters: piToolSchema(map[string]any{"path": str, "pattern": str}, "path", "pattern")}, + {Name: "grep", Description: "Search UTF-8 files recursively with a regular expression", Parameters: piToolSchema(map[string]any{"path": str, "pattern": str}, "path", "pattern")}, + {Name: "execute", Description: "Execute a shell command in the host task context with bounded output and process-tree cancellation", Parameters: piToolSchema(map[string]any{"command": str, "cwd": str}, "command")}, + } { + tools = append(tools, t) + names[t.Name] = true + } + } + if catalog.Len() > 0 { + return tools, names, "\n\nAvailable skills (load a matching skill before using its resources):\n" + catalog.String(), nil + } + return tools, names, "", nil +} + +type piBoundedOutput struct { + mu sync.Mutex + body strings.Builder + truncated bool + onChunk func(string) + spool io.Writer + spoolPath string +} + +func (b *piBoundedOutput) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.spool != nil { + if _, err := b.spool.Write(p); err != nil { + return 0, err + } + } + left := 256*1024 - b.body.Len() + if left > len(p) { + left = len(p) + } + if left > 0 { + b.body.Write(p[:left]) + if b.onChunk != nil { + b.onChunk(string(p[:left])) + } + } + if left < len(p) { + b.truncated = true + } + return len(p), nil +} +func (b *piBoundedOutput) String() string { + b.mu.Lock() + defer b.mu.Unlock() + text := b.body.String() + if b.truncated { + text += "\n[Preview truncated at 256 KiB; full output: " + b.spoolPath + "]" + } + return text +} + +func (r *piRun) executePiLocal(ctx context.Context, call agentruntime.ToolCall) (agentruntime.ToolResult, error) { + if call.Name == "TaskCreate" || call.Name == "TaskUpdate" || call.Name == "TaskGet" || call.Name == "TaskList" { + return r.executePiPlan(ctx, call) + } + arg := func(key string) string { v, _ := call.Arguments[key].(string); return v } + skillName := r.cfg.MultiAgent.EinoSkills.SkillToolName + if skillName == "" { + skillName = "skill" + } + if call.Name == skillName { + name := arg("name") + skills, err := skillpackage.ListSkillSummaries(r.cfg.SkillsDir) + if err != nil { + return agentruntime.ToolResult{}, err + } + found := false + for _, s := range skills { + if s.ID == name { + found = true + break + } + } + if !found { + return agentruntime.ToolResult{}, fmt.Errorf("unknown skill: %s", name) + } + view, err := skillpackage.LoadSkill(r.cfg.SkillsDir, name, skillpackage.LoadOptions{Depth: "full"}) + if err != nil { + return agentruntime.ToolResult{}, err + } + body, _ := json.Marshal(view) + return agentruntime.ToolResult{Content: string(body)}, nil + } + path := arg("path") + if path != "" { + var err error + path, err = filepath.Abs(path) + if err != nil { + return agentruntime.ToolResult{}, err + } + } + result := agentruntime.ToolResult{} + switch call.Name { + case "execute": + if arg("command") == "" { + return result, fmt.Errorf("command is required") + } + timeout := time.Duration(agentToolTimeoutMinutes(r.cfg)) * time.Minute + if timeout <= 0 { + timeout = 30 * time.Minute + } + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(execCtx, "/bin/sh", "-c", arg("command")) + cmd.Dir = arg("cwd") + security.ConfigureShellCmdForAgentExecute(cmd) + output := &piBoundedOutput{} + spoolPath, err := tooloutput.WriteTruncFile(tooloutput.SpillOpts{RootDir: r.cfg.MultiAgent.EinoMiddleware.ReductionRootDir, ConversationID: r.conversationID}, "") + if err != nil { + return result, err + } + spool, err := os.OpenFile(spoolPath, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return result, err + } + defer spool.Close() + output.spool = spool + output.spoolPath = spoolPath + cmd.Stdout = output + cmd.Stderr = output + id := r.ag.BeginLocalToolExecution(ctx, call.Name, call.Arguments) + r.ag.RegisterLocalToolExecutionCancel(id, cancel) + defer r.ag.UnregisterLocalToolExecutionCancel(id) + output.onChunk = func(chunk string) { r.ag.AppendLocalToolExecutionPartialOutput(id, chunk) } + session, err := security.StartShellSessionContext(execCtx, cmd) + if err == nil { + err = session.Wait() + } + result.Content = output.String() + result.IsError = err != nil + result.ExecutionID = r.ag.FinishLocalToolExecution(ctx, id, call.Name, call.Arguments, result.Content, err) + if err != nil { + result.Content += "\n" + err.Error() + } + return result, nil + case "read_file": + f, err := os.Open(path) + if err != nil { + return result, err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return result, err + } + if !st.Mode().IsRegular() { + return result, fmt.Errorf("not a regular file") + } + b, err := io.ReadAll(io.LimitReader(f, 2*1024*1024+1)) + if err != nil { + return result, err + } + if len(b) > 2*1024*1024 { + return result, fmt.Errorf("file exceeds 2 MiB; use execute to select a smaller region") + } + lines := strings.Split(string(b), "\n") + offset, limit := 1, 1000 + if n, ok := call.Arguments["offset"].(float64); ok && n >= 1 { + offset = int(n) + } + if n, ok := call.Arguments["limit"].(float64); ok && n > 0 && n < 10000 { + limit = int(n) + } + if offset > len(lines) { + return result, nil + } + end := offset - 1 + limit + if end > len(lines) { + end = len(lines) + } + result.Content = strings.Join(lines[offset-1:end], "\n") + case "write_file", "edit_file": + content := arg("content") + mode := os.FileMode(0o600) + if st, err := os.Stat(path); err == nil { + if !st.Mode().IsRegular() { + return result, fmt.Errorf("not a regular file") + } + mode = st.Mode().Perm() + } + if call.Name == "edit_file" { + b, err := os.ReadFile(path) + if err != nil { + return result, err + } + old := arg("old_text") + if old == "" || strings.Count(string(b), old) != 1 { + return result, fmt.Errorf("old_text must match exactly once") + } + content = strings.Replace(string(b), old, arg("new_text"), 1) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return result, err + } + result.Content = "Updated " + path + case "ls": + entries, err := os.ReadDir(path) + if err != nil { + return result, err + } + var names []string + for _, e := range entries { + n := e.Name() + if e.IsDir() { + n += "/" + } + names = append(names, n) + } + result.Content = strings.Join(names, "\n") + case "glob", "grep": + pattern := arg("pattern") + var re *regexp.Regexp + if call.Name == "grep" { + var err error + re, err = regexp.Compile(pattern) + if err != nil { + return result, err + } + } + var hits []string + err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return err + } + if len(hits) >= 1000 { + return fs.SkipAll + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "node_modules" { + return fs.SkipDir + } + return nil + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + if call.Name == "glob" { + matched, e := filepath.Match(pattern, d.Name()) + if e != nil { + return e + } + if matched { + hits = append(hits, p) + } + return nil + } + st, e := d.Info() + if e != nil || !st.Mode().IsRegular() || st.Size() > 2*1024*1024 { + return nil + } + b, e := os.ReadFile(p) + if e != nil { + return e + } + for i, line := range strings.Split(string(b), "\n") { + if re.MatchString(line) { + hits = append(hits, fmt.Sprintf("%s:%d:%s", p, i+1, line)) + if len(hits) >= 1000 { + break + } + } + } + return nil + }) + if err != nil { + return result, err + } + result.Content = strings.Join(hits, "\n") + default: + return result, fmt.Errorf("unknown local tool") + } + result.ExecutionID = r.ag.FinishLocalToolExecution(ctx, "", call.Name, call.Arguments, result.Content, nil) + return result, nil +} diff --git a/internal/multiagent/pi_model.go b/internal/multiagent/pi_model.go new file mode 100644 index 00000000..bbc8a20a --- /dev/null +++ b/internal/multiagent/pi_model.go @@ -0,0 +1,63 @@ +package multiagent + +import ( + "cyberstrike-ai/internal/agentruntime" + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/reasoning" + "strings" +) + +func piModelConfig(oa config.OpenAIConfig, intent *reasoning.ClientIntent, conversationID string) agentruntime.Model { + mode, effort := oa.Reasoning.ModeEffective(), oa.Reasoning.Effort + if intent != nil && oa.Reasoning.AllowClientReasoningEffective() { + if intent.Mode != "" { + mode = intent.Mode + } + if intent.Effort != "" { + effort = intent.Effort + } + } + thinking := "off" + if mode == "on" || (mode == "auto" && effort != "") { + thinking = effort + if thinking == "" { + thinking = "medium" + } + } + api := "openai-completions" + provider := strings.ToLower(strings.TrimSpace(oa.Provider)) + if provider == "claude" || provider == "anthropic" { + api = "anthropic-messages" + } else if oa.OpenAIToolsRequireResponses() { + api = "openai-responses" + } + base := strings.TrimRight(strings.TrimSpace(oa.BaseURL), "/") + if base == "" { + base = "https://api.openai.com/v1" + if api == "anthropic-messages" { + base = "https://api.anthropic.com" + } + } + headers := map[string]string{} + for key, value := range oa.Headers { + if strings.Contains(value, "%session_id%") && conversationID == "" { + continue + } + headers[key] = strings.ReplaceAll(value, "%session_id%", conversationID) + } + budget := oa.MaxTotalTokens + if budget <= 0 { + budget = oa.MaxCompletionTokensEffective() + } + window := budget + oa.MaxCompletionTokensEffective() + extra := reasoning.AgenticOpenAIExtraFields(&oa, intent) + if api == "openai-responses" { + extra = oa.Reasoning.ExtraRequestFields + } + compat := map[string]any{} + if oa.Reasoning.ProfileEffective() == "deepseek_compat" || (oa.Reasoning.ProfileEffective() == "auto" && oa.IsDeepSeekEndpointOrModel()) { + compat["thinkingFormat"] = "deepseek" + compat["requiresReasoningContentOnAssistantMessages"] = true + } + return agentruntime.Model{Provider: provider, API: api, ID: oa.Model, BaseURL: base, APIKey: oa.APIKey, Headers: headers, ContextWindow: window, InputBudget: budget, MaxTokens: oa.MaxCompletionTokensEffective(), Thinking: thinking, ExtraFields: extra, Compat: compat} +} diff --git a/internal/multiagent/pi_plan_tools.go b/internal/multiagent/pi_plan_tools.go new file mode 100644 index 00000000..cef18f95 --- /dev/null +++ b/internal/multiagent/pi_plan_tools.go @@ -0,0 +1,211 @@ +package multiagent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "cyberstrike-ai/internal/agentruntime" +) + +var piPlanMu sync.Mutex + +func piPlanTools() []agentruntime.Tool { + str := map[string]any{"type": "string"} + return []agentruntime.Tool{ + {Name: "TaskCreate", Description: "Create a persisted task on this conversation's progress board", Parameters: piToolSchema(map[string]any{"subject": str, "description": str, "activeForm": str}, "subject")}, + {Name: "TaskUpdate", Description: "Update an existing task's status or description", Parameters: piToolSchema(map[string]any{"taskId": str, "subject": str, "description": str, "activeForm": str, "status": map[string]any{"type": "string", "enum": []string{"pending", "in_progress", "completed", "deleted"}}}, "taskId")}, + {Name: "TaskGet", Description: "Read a task by its conversation-local ID", Parameters: piToolSchema(map[string]any{"taskId": str}, "taskId")}, + {Name: "TaskList", Description: "Read the current conversation progress board", Parameters: piToolSchema(map[string]any{})}, + } +} + +func (r *piRun) planDir() (string, error) { + if r.conversationID == "" || strings.ContainsAny(r.conversationID, "/\\") || r.conversationID == ".." { + return "", fmt.Errorf("valid conversation ID required for a task board") + } + root := r.cfg.SkillsDir + if root == "" { + return "", fmt.Errorf("skills directory required for task board") + } + rel := r.cfg.MultiAgent.EinoMiddleware.PlantaskRelDir + if rel == "" { + rel = ".eino/plantask" + } + return filepath.Join(root, rel, r.conversationID), nil +} + +func readPiPlan(dir string) ([]map[string]any, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return []map[string]any{}, nil + } + if err != nil { + return nil, err + } + var tasks []map[string]any + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + if _, err := strconv.Atoi(strings.TrimSuffix(e.Name(), ".json")); err != nil { + continue + } + b, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + var task map[string]any + if err = json.Unmarshal(b, &task); err != nil { + return nil, err + } + tasks = append(tasks, task) + } + sort.Slice(tasks, func(i, j int) bool { + a, _ := strconv.Atoi(fmt.Sprint(tasks[i]["id"])) + b, _ := strconv.Atoi(fmt.Sprint(tasks[j]["id"])) + return a < b + }) + return tasks, nil +} + +func writePiPlanTask(dir string, task map[string]any) error { + id := fmt.Sprint(task["id"]) + if n, err := strconv.Atoi(id); err != nil || n <= 0 { + return fmt.Errorf("invalid task ID") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + b, err := json.Marshal(task) + if err != nil { + return err + } + f, err := os.CreateTemp(dir, ".task-") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err = f.Write(b); err != nil { + f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), filepath.Join(dir, id+".json")) +} + +func (r *piRun) executePiPlan(ctx context.Context, call agentruntime.ToolCall) (agentruntime.ToolResult, error) { + if err := ctx.Err(); err != nil { + return agentruntime.ToolResult{}, err + } + dir, err := r.planDir() + if err != nil { + return agentruntime.ToolResult{}, err + } + piPlanMu.Lock() + defer piPlanMu.Unlock() + tasks, err := readPiPlan(dir) + if err != nil { + return agentruntime.ToolResult{}, err + } + var output any + if call.Name == "write_todos" { + todos, ok := call.Arguments["todos"].([]any) + if !ok { + return agentruntime.ToolResult{}, fmt.Errorf("todos must be an array") + } + var replacement []map[string]any + for i, entry := range todos { + todo, ok := entry.(map[string]any) + if !ok { + return agentruntime.ToolResult{}, fmt.Errorf("invalid todo") + } + subject, _ := todo["content"].(string) + status, _ := todo["status"].(string) + if subject == "" || (status != "pending" && status != "in_progress" && status != "completed") { + return agentruntime.ToolResult{}, fmt.Errorf("invalid todo subject/status") + } + replacement = append(replacement, map[string]any{"id": strconv.Itoa(i + 1), "subject": subject, "status": status}) + } + for _, task := range replacement { + if err = writePiPlanTask(dir, task); err != nil { + return agentruntime.ToolResult{}, err + } + } + for _, task := range tasks { + id, _ := strconv.Atoi(fmt.Sprint(task["id"])) + if id > len(replacement) { + task["status"] = "deleted" + if err = writePiPlanTask(dir, task); err != nil { + return agentruntime.ToolResult{}, err + } + } + } + output = map[string]any{"tasks": replacement} + } else if call.Name == "TaskList" { + visible := []map[string]any{} + for _, task := range tasks { + if task["status"] != "deleted" { + visible = append(visible, task) + } + } + output = map[string]any{"tasks": visible} + } else if call.Name == "TaskCreate" { + subject, _ := call.Arguments["subject"].(string) + if strings.TrimSpace(subject) == "" { + return agentruntime.ToolResult{}, fmt.Errorf("subject is required") + } + id := 1 + for _, task := range tasks { + n, _ := strconv.Atoi(fmt.Sprint(task["id"])) + if n >= id { + id = n + 1 + } + } + task := map[string]any{"id": strconv.Itoa(id), "subject": subject, "status": "pending", "description": call.Arguments["description"], "activeForm": call.Arguments["activeForm"]} + if err = writePiPlanTask(dir, task); err != nil { + return agentruntime.ToolResult{}, err + } + output = task + } else { + id, _ := call.Arguments["taskId"].(string) + var selected map[string]any + for _, task := range tasks { + if fmt.Sprint(task["id"]) == id { + selected = task + break + } + } + if selected == nil { + return agentruntime.ToolResult{}, fmt.Errorf("task does not exist") + } + if call.Name == "TaskUpdate" { + for _, key := range []string{"subject", "description", "activeForm", "status"} { + if v, ok := call.Arguments[key]; ok { + value, ok := v.(string) + if !ok { + return agentruntime.ToolResult{}, fmt.Errorf("%s must be a string", key) + } + if key == "status" && value != "pending" && value != "in_progress" && value != "completed" && value != "deleted" { + return agentruntime.ToolResult{}, fmt.Errorf("invalid task status") + } + selected[key] = v + } + } + if err = writePiPlanTask(dir, selected); err != nil { + return agentruntime.ToolResult{}, err + } + } + output = selected + } + body, err := json.Marshal(output) + return agentruntime.ToolResult{Content: string(body)}, err +} diff --git a/internal/multiagent/pi_plan_tools_test.go b/internal/multiagent/pi_plan_tools_test.go new file mode 100644 index 00000000..4ea3c698 --- /dev/null +++ b/internal/multiagent/pi_plan_tools_test.go @@ -0,0 +1,55 @@ +package multiagent + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "cyberstrike-ai/internal/agentruntime" + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "go.uber.org/zap" +) + +func TestPiPlanBoardRemainsReadableByConversationAPI(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{SkillsDir: dir} + r := &piRun{cfg: cfg, conversationID: "fixture"} + db, err := database.NewDB(filepath.Join(dir, "fixture.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.SetEinoConversationDirs(filepath.Join(dir, ".eino", "plantask"), "", "", "") + created, err := r.executePiPlan(context.Background(), agentruntime.ToolCall{Name: "TaskCreate", Arguments: map[string]any{"subject": "Collect evidence"}}) + if err != nil { + t.Fatal(err) + } + var task map[string]any + if err = json.Unmarshal([]byte(created.Content), &task); err != nil { + t.Fatal(err) + } + _, err = r.executePiPlan(context.Background(), agentruntime.ToolCall{Name: "TaskUpdate", Arguments: map[string]any{"taskId": task["id"], "status": "completed"}}) + if err != nil { + t.Fatal(err) + } + tasks, err := db.ListConversationPlanTasks("fixture") + if err != nil { + t.Fatal(err) + } + if len(tasks) != 1 || tasks[0].Status != "completed" || tasks[0].Subject != "Collect evidence" { + t.Fatalf("API lost plan state: %+v", tasks) + } + _, err = r.executePiPlan(context.Background(), agentruntime.ToolCall{Name: "write_todos", Arguments: map[string]any{"todos": []any{map[string]any{"content": "Verify result", "status": "in_progress"}}}}) + if err != nil { + t.Fatal(err) + } + tasks, err = db.ListConversationPlanTasks("fixture") + if err != nil { + t.Fatal(err) + } + if len(tasks) != 1 || tasks[0].Subject != "Verify result" || tasks[0].Status != "in_progress" { + t.Fatalf("replacement board incorrect: %+v", tasks) + } +} diff --git a/internal/multiagent/pi_runner.go b/internal/multiagent/pi_runner.go new file mode 100644 index 00000000..d2f2f111 --- /dev/null +++ b/internal/multiagent/pi_runner.go @@ -0,0 +1,463 @@ +package multiagent + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "cyberstrike-ai/internal/agent" + "cyberstrike-ai/internal/agentruntime" + "cyberstrike-ai/internal/agents" + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/project" + "cyberstrike-ai/internal/reasoning" + "cyberstrike-ai/internal/tooloutput" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type piRun struct { + cfg *config.Config + ag *agent.Agent + conversationID, agentID, systemPrompt, sessionKey string + history []agent.ChatMessage + roleTools []string + catalogAllow map[string]bool + availableCatalog map[string]bool + progress func(string, string, interface{}) + reasoning *reasoning.ClientIntent + child bool + maxTurns int + extraTools []agentruntime.Tool + extraExecute agentruntime.ToolExecutor +} + +func runPiSingle(ctx context.Context, cfg *config.Config, ag *agent.Agent, db *database.DB, logger *zap.Logger, + conversationID, projectID, message string, history []agent.ChatMessage, roleTools []string, + progress func(string, string, interface{}), intent *reasoning.ClientIntent, extra string) (*RunResult, error) { + if ag == nil { + return nil, fmt.Errorf("Pi agent is nil") + } + prompt := project.AppendSystemPromptBlock(ag.EinoSingleAgentSystemInstruction(), extra) + prompt = project.AppendVisionImageAnalysisIfReady(prompt, cfg.Vision.Ready()) + return (&piRun{cfg: cfg, ag: ag, conversationID: conversationID, agentID: "main", systemPrompt: prompt, + history: history, roleTools: roleTools, progress: progress, reasoning: intent}).run(ctx, message) +} + +func piToolSchema(properties map[string]any, required ...string) map[string]any { + return map[string]any{"type": "object", "properties": properties, "required": required} +} + +func runPiMulti(ctx context.Context, cfg *config.Config, ma *config.MultiAgentConfig, ag *agent.Agent, db *database.DB, logger *zap.Logger, + conversationID, projectID, message string, history []agent.ChatMessage, roleTools []string, + progress func(string, string, interface{}), markdownDir, mode string, intent *reasoning.ClientIntent, extra string) (*RunResult, error) { + if ag == nil || ma == nil { + return nil, fmt.Errorf("Pi agent/config is nil") + } + if mode == "" { + mode = ma.Orchestration + } + mode = config.NormalizeMultiAgentOrchestration(mode) + subs := ma.SubAgents + var loaded *agents.MarkdownDirLoad + if markdownDir != "" { + var err error + loaded, err = agents.LoadMarkdownAgentsDir(markdownDir) + if err != nil { + return nil, fmt.Errorf("load Pi agent roles: %w", err) + } + subs = agents.MergeYAMLAndMarkdown(subs, loaded.SubAgents) + } + if mode == "plan_execute" { + subs = []config.MultiAgentSubConfig{{ID: "executor", Description: "Execute one concrete plan step and return observed evidence", Instruction: ag.EinoSingleAgentSystemInstruction(), RoleTools: roleTools}} + } + if len(subs) == 0 && !ma.WithoutGeneralSubAgent && mode != "supervisor" { + subs = []config.MultiAgentSubConfig{{ID: "general", Description: "General task specialist", Instruction: ag.EinoSingleAgentSystemInstruction(), RoleTools: roleTools}} + } + if len(subs) == 0 { + return nil, fmt.Errorf("Pi %s requires at least one configured sub-agent", mode) + } + registry := map[string]config.MultiAgentSubConfig{} + var catalog []string + for _, sub := range subs { + id := strings.TrimSpace(sub.ID) + if id == "" { + id = sub.Name + } + if id == "" { + return nil, fmt.Errorf("Pi sub-agent has no ID") + } + if _, exists := registry[id]; exists { + return nil, fmt.Errorf("duplicate Pi sub-agent: %s", id) + } + registry[id] = sub + catalog = append(catalog, id+": "+sub.Description) + } + prompt, _ := resolveMainOrchestratorInstruction(mode, ma, loaded) + prompt = project.AppendSystemPromptBlock(prompt, extra) + prompt += "\n\nAvailable specialists:\n" + strings.Join(catalog, "\n") + "\nUse task to delegate a concrete objective. Each child receives only the tools permitted for it and this conversation. Verify evidence before reporting completion." + if mode == "plan_execute" { + prompt += "\nFirst create a concrete plan with write_todos. Delegate one step at a time to executor using task, examine the result, update the plan, and replan when evidence requires it. Finish with the verified deliverable." + } + main := &piRun{cfg: cfg, ag: ag, conversationID: conversationID, agentID: "main", systemPrompt: prompt, history: history, roleTools: roleTools, progress: progress, reasoning: intent, sessionKey: mode} + str := map[string]any{"type": "string"} + main.extraTools = []agentruntime.Tool{ + {Name: "task", Description: "Delegate a concrete task to a named specialist and return its evidence-backed result", Parameters: piToolSchema(map[string]any{"subagent_type": str, "description": str}, "subagent_type", "description")}, + {Name: "write_todos", Description: "Record/update the execution plan", Parameters: piToolSchema(map[string]any{"todos": map[string]any{"type": "array", "items": map[string]any{"type": "object", "properties": map[string]any{"content": str, "status": map[string]any{"type": "string", "enum": []string{"pending", "in_progress", "completed"}}}, "required": []string{"content", "status"}}}}, "todos")}, + } + var idsMu sync.Mutex + var childIDs []string + var deliverable string + main.extraTools = append(main.extraTools, agentruntime.Tool{Name: "exit", Description: "Finish with the verified final deliverable", Parameters: piToolSchema(map[string]any{"final_result": str}, "final_result")}) + if mode == "supervisor" { + main.extraTools = append(main.extraTools, agentruntime.Tool{Name: "transfer_to_agent", Description: "Delegate to a named specialist", Parameters: piToolSchema(map[string]any{"agent_name": str, "description": str}, "agent_name", "description")}) + } + main.extraExecute = func(callCtx context.Context, call agentruntime.ToolCall) (agentruntime.ToolResult, error) { + if call.Name == "exit" { + text, _ := call.Arguments["final_result"].(string) + if strings.TrimSpace(text) == "" { + return agentruntime.ToolResult{}, fmt.Errorf("final_result is required") + } + idsMu.Lock() + deliverable = text + idsMu.Unlock() + return agentruntime.ToolResult{Content: text, Terminate: true}, nil + } + if call.Name == "write_todos" { + if cfg.MultiAgent.EinoMiddleware.PlantaskEnable && cfg.SkillsDir != "" { + return main.executePiPlan(callCtx, call) + } + b, _ := json.Marshal(call.Arguments) + if progress != nil { + progress("plan_update", string(b), call.Arguments) + } + return agentruntime.ToolResult{Content: string(b)}, nil + } + id, _ := call.Arguments["subagent_type"].(string) + if call.Name == "transfer_to_agent" { + id, _ = call.Arguments["agent_name"].(string) + } + task, _ := call.Arguments["description"].(string) + sub, ok := registry[id] + if !ok || strings.TrimSpace(task) == "" { + return agentruntime.ToolResult{}, fmt.Errorf("unknown specialist or empty task") + } + tools := sub.RoleTools + if sub.BindRole != "" { + role, ok := cfg.Roles[sub.BindRole] + if !ok || !role.Enabled { + return agentruntime.ToolResult{}, fmt.Errorf("sub-agent role unavailable: %s", sub.BindRole) + } + if len(tools) == 0 { + tools = role.Tools + } + } + // A child may narrow its parent's catalog, never expand it. + parentNames := main.availableCatalog + childPrompt := project.AppendSystemPromptBlock(sub.Instruction, extra) + "\n\nOriginal user task:\n" + message + child := &piRun{cfg: cfg, ag: ag, conversationID: conversationID, agentID: id, systemPrompt: childPrompt, roleTools: tools, catalogAllow: parentNames, progress: progress, reasoning: intent, child: true, sessionKey: call.ID, maxTurns: sub.MaxIterations} + res, err := child.run(callCtx, task) + if res == nil { + return agentruntime.ToolResult{}, err + } + idsMu.Lock() + childIDs = append(childIDs, res.MCPExecutionIDs...) + idsMu.Unlock() + return agentruntime.ToolResult{Content: res.Response, IsError: err != nil}, err + } + res, err := main.run(ctx, message) + if res != nil { + idsMu.Lock() + if deliverable != "" { + res.Response = deliverable + res.LastAgentTraceOutput = deliverable + } + res.MCPExecutionIDs = append(res.MCPExecutionIDs, childIDs...) + idsMu.Unlock() + } + return res, err +} + +func (r *piRun) run(ctx context.Context, message string) (*RunResult, error) { + defs := r.ag.ToolsForRole(r.roleTools) + localDefs, localNames, skillPrompt, err := r.piLocalCatalog(ctx) + if err != nil { + return nil, err + } + r.systemPrompt += skillPrompt + allowed := map[string]bool{} + tools := make([]agentruntime.Tool, 0, len(defs)+len(r.extraTools)) + for _, d := range defs { + if r.catalogAllow != nil && !r.catalogAllow[d.Function.Name] { + continue + } + allowed[d.Function.Name] = true + tools = append(tools, agentruntime.Tool{Name: d.Function.Name, Description: d.Function.Description, Parameters: d.Function.Parameters}) + } + for _, t := range localDefs { + if r.catalogAllow != nil && !r.catalogAllow[t.Name] { + delete(localNames, t.Name) + continue + } + if !allowed[t.Name] { + tools = append(tools, t) + allowed[t.Name] = true + } else { + delete(localNames, t.Name) + } + } + extraNames := map[string]bool{} + r.availableCatalog = allowed + for _, t := range r.extraTools { + if allowed[t.Name] { + return nil, fmt.Errorf("Pi tool name collision: %s", t.Name) + } + extraNames[t.Name] = true + tools = append(tools, t) + } + oa := r.cfg.OpenAI + root := r.cfg.AgentRuntime.SessionDir + if root == "" { + root = filepath.Join(filepath.Dir(r.cfg.Database.Path), "pi-sessions") + if r.cfg.Database.Path == "" { + root = "data/pi-sessions" + } + } + key := fmt.Sprintf("%x", sha256.Sum256([]byte(r.conversationID+"\x00"+r.agentID+"\x00"+r.sessionKey))) + history, err := json.Marshal(r.history) + if err != nil { + return nil, err + } + max := r.maxTurns + if max <= 0 { + max = agentMaxIterations(r.cfg) + } + runID := uuid.NewString() + req := agentruntime.Request{RunID: runID, ConversationID: r.conversationID, SystemPrompt: r.systemPrompt, Message: message, History: history, + Model: piModelConfig(oa, r.reasoning, r.conversationID), + Tools: tools, SessionDir: filepath.Join(root, key), MaxTurns: max} + if strings.TrimSpace(r.conversationID) == "" { + req.SessionDir = "" + } + req.ToolSearch = r.cfg.MultiAgent.EinoMiddleware.ToolSearchEnable + req.ToolSearchMinTools = r.cfg.MultiAgent.EinoMiddleware.ToolSearchMinTools + req.AlwaysVisibleTools = append([]string(nil), r.cfg.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools...) + visibleCount := r.cfg.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisible + if visibleCount <= 0 { + visibleCount = 12 + } + for i, t := range tools { + if i >= visibleCount { + break + } + req.AlwaysVisibleTools = append(req.AlwaysVisibleTools, t.Name) + } + req.RetryMax = r.cfg.MultiAgent.EinoMiddleware.ModelRetryMaxRetries + for _, id := range r.cfg.MultiAgent.EinoMiddleware.ModelFailoverChannels { + ch, _, ok := r.cfg.ResolveAIChannel(id) + if !ok { + return nil, fmt.Errorf("unknown fallback AI channel: %s", id) + } + if ch.Model == oa.Model && ch.BaseURL == oa.BaseURL && ch.Provider == oa.Provider { + continue + } + fallback := piModelConfig(ch, r.reasoning, r.conversationID) + req.FallbackModels = append(req.FallbackModels, fallback) + if cap := r.cfg.MultiAgent.EinoMiddleware.ModelFailoverMaxRetries; cap > 0 && len(req.FallbackModels) >= cap { + break + } + } + var idsMu sync.Mutex + var ids []string + var progressMu sync.Mutex + var iteration atomic.Int32 + progress := func(kind, text string, data map[string]any) { + if r.progress == nil { + return + } + progressMu.Lock() + defer progressMu.Unlock() + data["source"] = "pi" + if strings.HasPrefix(kind, "response_") { + data["streamId"] = fmt.Sprintf("%s-%d", runID, iteration.Load()) + data["iteration"] = iteration.Load() + data["messageGeneratedBy"] = "pi:" + r.agentID + idsMu.Lock() + data["mcpExecutionIds"] = append([]string(nil), ids...) + idsMu.Unlock() + } + data["runId"] = runID + data["conversationId"] = r.conversationID + data["einoAgent"] = r.agentID + data["einoRole"] = "orchestrator" + if r.child { + data["einoRole"] = "subagent" + } + r.progress(kind, text, data) + } + invoke := func(callCtx context.Context, call agentruntime.ToolCall) (agentruntime.ToolResult, error) { + if !allowed[call.Name] && !extraNames[call.Name] { + return agentruntime.ToolResult{}, fmt.Errorf("tool not allowed: %s", call.Name) + } + if _, marked := call.Arguments[modelOutputRecoveryKey]; marked { + return agentruntime.ToolResult{}, fmt.Errorf("legacy model-output recovery markers cannot be executed") + } + if localToolPermissionDenied(callCtx, call.Name) { + return agentruntime.ToolResult{}, fmt.Errorf("agent:local-execute permission required") + } + body, _ := json.Marshal(call.Arguments) + if fn, _ := callCtx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); fn != nil { + edited, err := fn(callCtx, call.Name, string(body)) + if err != nil { + return agentruntime.ToolResult{}, err + } + if edited != "" { + if err = json.Unmarshal([]byte(edited), &call.Arguments); err != nil { + return agentruntime.ToolResult{}, fmt.Errorf("invalid approved tool arguments: %w", err) + } + } + } + progress("tool_call", "", map[string]any{"toolName": call.Name, "toolCallId": call.ID, "arguments": call.Arguments}) + var result agentruntime.ToolResult + var err error + if localNames[call.Name] { + result, err = r.executePiLocal(callCtx, call) + } else if extraNames[call.Name] { + result, err = r.extraExecute(callCtx, call) + } else { + res, invokeErr := r.ag.ExecuteMCPToolForConversation(callCtx, r.conversationID, call.Name, call.Arguments) + err = invokeErr + if res != nil { + result = agentruntime.ToolResult{Content: res.Result, IsError: res.IsError || res.Blocked, ExecutionID: res.ExecutionID} + if res.ExecutionID != "" { + idsMu.Lock() + ids = append(ids, res.ExecutionID) + idsMu.Unlock() + } + } + } + if err != nil { + result.Content = err.Error() + result.IsError = true + } + if r.cfg.MultiAgent.EinoMiddleware.ReductionEnable { + result.Content = tooloutput.BoundWithSpill(result.Content, r.cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective(), tooloutput.SpillOpts{RootDir: r.cfg.MultiAgent.EinoMiddleware.ReductionRootDir, ConversationID: r.conversationID, ExecutionID: result.ExecutionID}) + } + progress("tool_result", result.Content, map[string]any{"toolName": call.Name, "toolCallId": call.ID, "executionId": result.ExecutionID, "result": result.Content, "success": !result.IsError, "isError": result.IsError, "agentFacing": true}) + return result, err + } + var text, thought string + var modelCalls, promptTokens, completionTokens, cacheRead, cacheWrite int + reasonID := func() string { return fmt.Sprintf("%s-reasoning-%d", runID, iteration.Load()) } + emit := func(e agentruntime.Event) { + if e.Type == "turn_start" { + if thought != "" { + progress("reasoning_chain_stream_end", thought, map[string]any{"streamId": reasonID()}) + } + text = "" + thought = "" + iteration.Add(1) + } + if e.Type == "turn_end" { + var d struct { + Message struct { + Usage struct { + Input int `json:"input"` + Output int `json:"output"` + CacheRead int `json:"cacheRead"` + CacheWrite int `json:"cacheWrite"` + } `json:"usage"` + } `json:"message"` + } + if json.Unmarshal(e.Data, &d) == nil { + modelCalls++ + promptTokens += d.Message.Usage.Input + d.Message.Usage.CacheRead + d.Message.Usage.CacheWrite + completionTokens += d.Message.Usage.Output + cacheRead += d.Message.Usage.CacheRead + cacheWrite += d.Message.Usage.CacheWrite + } + } + + if e.Type == "message_update" { + var data struct { + Delta struct { + Type string `json:"type"` + Delta string `json:"delta"` + } `json:"assistantMessageEvent"` + } + if json.Unmarshal(e.Data, &data) != nil { + return + } + switch data.Delta.Type { + case "text_delta": + if r.child { + return + } + if text == "" { + progress("response_start", "", map[string]any{}) + } + text += data.Delta.Delta + progress("response_delta", data.Delta.Delta, map[string]any{"accumulated": text}) + case "thinking_delta": + if thought == "" { + progress("reasoning_chain_stream_start", " ", map[string]any{"streamId": reasonID()}) + } + thought += data.Delta.Delta + progress("reasoning_chain_stream_delta", data.Delta.Delta, map[string]any{"streamId": reasonID(), "accumulated": thought}) + } + } else if e.Type == "auto_compaction_start" || e.Type == "auto_retry_start" || e.Type == "model_failover" { + progress("progress", e.Type, map[string]any{}) + } + } + bind := func(control agentruntime.Control) func() { + var undo []func() + if !r.child { + if registrar := agentRuntimeCancelRegistrarFromContext(ctx); registrar != nil { + undo = append(undo, registrar(func(error) bool { return control.Cancel() == nil })) + } + if registrar := agentTurnLoopInterruptRegistrarFromContext(ctx); registrar != nil { + undo = append(undo, registrar(func(note string) bool { return control.Steer(note) == nil })) + } + } + return func() { + for _, f := range undo { + if f != nil { + f() + } + } + } + } + res, runErr := (agentruntime.Pi{NodePath: r.cfg.AgentRuntime.NodePath, WorkerPath: r.cfg.AgentRuntime.WorkerPath}).Run(ctx, req, invoke, emit, bind) + if thought != "" { + progress("reasoning_chain_stream_end", thought, map[string]any{"streamId": reasonID()}) + } + if modelCalls > 0 { + progress("eino_usage_summary", "Pi token usage summary", map[string]any{"model": oa.Model, "modelCalls": modelCalls, "promptTokens": promptTokens, "completionTokens": completionTokens, "totalTokens": promptTokens + completionTokens, "cachedTokens": cacheRead, "cacheWriteTokens": cacheWrite}) + } + if res == nil { + return nil, runErr + } + idsMu.Lock() + out := &RunResult{Response: res.Response, LastAgentTraceInput: string(res.History), LastAgentTraceOutput: res.Response, MCPExecutionIDs: append([]string(nil), ids...), Status: res.Status} + switch res.Status { + case "iteration_limit": + out.RuntimeStatus = "blocked" + out.RuntimeCompletionReason = "iteration_limit" + case "canceled": + out.RuntimeStatus = "cancelled" + case "failed": + out.RuntimeStatus = "failed" + } + idsMu.Unlock() + if r.child { + progress("sub_agent_result", res.Response, map[string]any{"agentName": r.agentID}) + } + return out, runErr +} diff --git a/internal/multiagent/runner.go b/internal/multiagent/runner.go index 6b2e1e4f..e02e8316 100644 --- a/internal/multiagent/runner.go +++ b/internal/multiagent/runner.go @@ -33,17 +33,19 @@ import ( // RunResult 与单 Agent 循环结果字段对齐,便于复用存储与 SSE 收尾逻辑。 type RunResult struct { - Response string - MCPExecutionIDs []string - LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文 - LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复) - Finalized bool - Status string - CompletionReason string - EvidenceVerified bool - EvidenceRefs []string - PendingExecutionIDs []string - MissingChecks []string + RuntimeStatus string // immutable runtime outcome; delivery finalization may update Status separately + RuntimeCompletionReason string + Response string + MCPExecutionIDs []string + LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文 + LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复) + Finalized bool + Status string + CompletionReason string + EvidenceVerified bool + EvidenceRefs []string + PendingExecutionIDs []string + MissingChecks []string } // toolCallPendingInfo tracks a tool_call emitted to the UI so we can later diff --git a/internal/multiagent/runtime_dispatch.go b/internal/multiagent/runtime_dispatch.go new file mode 100644 index 00000000..dd69462e --- /dev/null +++ b/internal/multiagent/runtime_dispatch.go @@ -0,0 +1,55 @@ +package multiagent + +import ( + "context" + "cyberstrike-ai/internal/agent" + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/reasoning" + "go.uber.org/zap" +) + +func RunSingleAgent( + ctx context.Context, + appCfg *config.Config, + ma *config.MultiAgentConfig, + ag *agent.Agent, + db *database.DB, + logger *zap.Logger, + conversationID string, + projectID string, + userMessage string, + history []agent.ChatMessage, + roleTools []string, + progress func(eventType, message string, data interface{}), + reasoningClient *reasoning.ClientIntent, + systemPromptExtra string, +) (*RunResult, error) { + if appCfg != nil && appCfg.AgentRuntime.UsesPi() { + return runPiSingle(ctx, appCfg, ag, db, logger, conversationID, projectID, userMessage, history, roleTools, progress, reasoningClient, systemPromptExtra) + } + return RunEinoSingleChatModelAgent(ctx, appCfg, ma, ag, db, logger, conversationID, projectID, userMessage, history, roleTools, progress, reasoningClient, systemPromptExtra) +} +func RunMultiAgent( + ctx context.Context, + appCfg *config.Config, + ma *config.MultiAgentConfig, + ag *agent.Agent, + db *database.DB, + logger *zap.Logger, + conversationID string, + projectID string, + userMessage string, + history []agent.ChatMessage, + roleTools []string, + progress func(eventType, message string, data interface{}), + agentsMarkdownDir string, + orchestrationOverride string, + reasoningClient *reasoning.ClientIntent, + systemPromptExtra string, +) (*RunResult, error) { + if appCfg != nil && appCfg.AgentRuntime.UsesPi() { + return runPiMulti(ctx, appCfg, ma, ag, db, logger, conversationID, projectID, userMessage, history, roleTools, progress, agentsMarkdownDir, orchestrationOverride, reasoningClient, systemPromptExtra) + } + return RunDeepAgent(ctx, appCfg, ma, ag, db, logger, conversationID, projectID, userMessage, history, roleTools, progress, agentsMarkdownDir, orchestrationOverride, reasoningClient, systemPromptExtra) +} diff --git a/internal/openai/claude_native.go b/internal/openai/claude_native.go index d084139f..c982a31e 100644 --- a/internal/openai/claude_native.go +++ b/internal/openai/claude_native.go @@ -1,17 +1,14 @@ package openai import ( + "bufio" + "bytes" "context" "encoding/json" - "errors" "fmt" "io" + "net/http" "strings" - - "cyberstrike-ai/internal/llm" - - "github.com/cloudwego/eino/components/model" - "github.com/cloudwego/eino/schema" ) type claudeNativePayload struct { @@ -24,110 +21,31 @@ type claudeNativePayload struct { Thinking any `json:"thinking,omitempty"` OutputConfig any `json:"output_config,omitempty"` } - type claudeNativeMessage struct { Role string `json:"role"` Content json.RawMessage `json:"content"` } func (c *Client) isClaude() bool { - return c != nil && c.config != nil && llm.IsClaudeProvider(c.config.Provider) + return c != nil && c.config != nil && (strings.EqualFold(c.config.Provider, "claude") || strings.EqualFold(c.config.Provider, "anthropic")) } - -func (c *Client) claudeNativeChatCompletion(ctx context.Context, payload, out any) error { - req, err := c.parseClaudeNativePayload(payload) - if err != nil { - return err - } - nativeModel, err := llm.NewClaudeAgenticModel( - ctx, - *c.config, - c.httpClient, - req.maxTokens(c.config.MaxCompletionTokensEffective()), - req.extraFields(), - ) - if err != nil { - return fmt.Errorf("create native Claude model: %w", err) - } - resp, err := nativeModel.Generate(ctx, req.agenticMessages(), req.options()...) - if err != nil { - return fmt.Errorf("native Claude generate: %w", err) - } - return marshalClaudeNativeResponse(resp, req.model(c.config.Model), out) -} - -func (c *Client) claudeNativeChatCompletionStream( - ctx context.Context, - payload any, - onDelta func(delta string) error, -) (string, error) { - req, err := c.parseClaudeNativePayload(payload) - if err != nil { - return "", err - } - nativeModel, err := llm.NewClaudeAgenticModel( - ctx, - *c.config, - c.httpClient, - req.maxTokens(c.config.MaxCompletionTokensEffective()), - req.extraFields(), - ) - if err != nil { - return "", fmt.Errorf("create native Claude model: %w", err) - } - stream, err := nativeModel.Stream(ctx, req.agenticMessages(), req.options()...) - if err != nil { - return "", fmt.Errorf("native Claude stream: %w", err) - } - defer stream.Close() - - var full strings.Builder - for { - chunk, recvErr := stream.Recv() - if errors.Is(recvErr, io.EOF) { - return full.String(), nil - } - if recvErr != nil { - return full.String(), fmt.Errorf("native Claude stream receive: %w", recvErr) - } - content, _ := llm.AgenticText(chunk) - if content == "" { - continue - } - full.WriteString(content) - if onDelta != nil { - if err := onDelta(content); err != nil { - return full.String(), err - } - } - } -} - func (c *Client) parseClaudeNativePayload(payload any) (*claudeNativePayload, error) { raw, err := json.Marshal(payload) if err != nil { - return nil, fmt.Errorf("marshal Claude payload: %w", err) + return nil, err } var req claudeNativePayload - if err := json.Unmarshal(raw, &req); err != nil { - return nil, fmt.Errorf("unmarshal Claude payload: %w", err) + if err = json.Unmarshal(raw, &req); err != nil { + return nil, err } - if strings.TrimSpace(req.model(c.config.Model)) == "" { - return nil, fmt.Errorf("native Claude model is empty") + if req.Model == "" { + req.Model = c.config.Model } - if len(req.Messages) == 0 { - return nil, fmt.Errorf("native Claude messages are empty") + if req.Model == "" || len(req.Messages) == 0 { + return nil, fmt.Errorf("Claude model/messages are required") } return &req, nil } - -func (p *claudeNativePayload) model(fallback string) string { - if modelName := strings.TrimSpace(p.Model); modelName != "" { - return modelName - } - return strings.TrimSpace(fallback) -} - func (p *claudeNativePayload) maxTokens(fallback int) int { if p.MaxCompletionTokens > 0 { return p.MaxCompletionTokens @@ -137,109 +55,209 @@ func (p *claudeNativePayload) maxTokens(fallback int) int { } return fallback } - -func (p *claudeNativePayload) extraFields() map[string]any { - fields := make(map[string]any, 2) - if p.Thinking != nil { - fields["thinking"] = p.Thinking - } - if p.OutputConfig != nil { - fields["output_config"] = p.OutputConfig - } - if len(fields) == 0 { - return nil - } - return fields -} - -func (p *claudeNativePayload) options() []model.Option { - opts := make([]model.Option, 0, 3) - if p.Temperature != nil { - opts = append(opts, model.WithTemperature(*p.Temperature)) - } - if p.TopP != nil { - opts = append(opts, model.WithTopP(*p.TopP)) - } - if p.MaxCompletionTokens > 0 || p.MaxTokens > 0 { - opts = append(opts, model.WithMaxTokens(p.maxTokens(0))) - } - return opts -} - -func (p *claudeNativePayload) agenticMessages() []*schema.AgenticMessage { - out := make([]*schema.AgenticMessage, 0, len(p.Messages)) - for _, msg := range p.Messages { - text := claudeNativeTextContent(msg.Content) - role := schema.AgenticRoleTypeUser - var block *schema.ContentBlock - switch strings.ToLower(strings.TrimSpace(msg.Role)) { - case "system": - role = schema.AgenticRoleTypeSystem - block = schema.NewContentBlock(&schema.UserInputText{Text: text}) - case "assistant": - role = schema.AgenticRoleTypeAssistant - block = schema.NewContentBlock(&schema.AssistantGenText{Text: text}) - default: - block = schema.NewContentBlock(&schema.UserInputText{Text: text}) - } - out = append(out, &schema.AgenticMessage{ - Role: role, - ContentBlocks: []*schema.ContentBlock{block}, - }) - } - return out -} - func claudeNativeTextContent(raw json.RawMessage) string { var text string - if err := json.Unmarshal(raw, &text); err == nil { + if json.Unmarshal(raw, &text) == nil { return text } var parts []struct { Type string `json:"type"` Text string `json:"text"` } - if err := json.Unmarshal(raw, &parts); err == nil { - var out strings.Builder - for _, part := range parts { - if part.Type == "" || part.Type == "text" { - out.WriteString(part.Text) + if json.Unmarshal(raw, &parts) == nil { + var out []string + for _, p := range parts { + if p.Type == "text" { + out = append(out, p.Text) } } - return out.String() + return strings.Join(out, "\n") } - return strings.TrimSpace(string(raw)) + return string(raw) } - -func marshalClaudeNativeResponse(resp *schema.AgenticMessage, modelName string, out any) error { +func claudeNativeBlocks(raw json.RawMessage) any { + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var blocks []map[string]any + if json.Unmarshal(raw, &blocks) != nil { + return claudeNativeTextContent(raw) + } + var out []map[string]any + for _, b := range blocks { + if b["type"] == "text" { + out = append(out, b) + continue + } + if b["type"] == "image_url" { + image, _ := b["image_url"].(map[string]any) + url, _ := image["url"].(string) + if strings.HasPrefix(url, "data:") { + parts := strings.SplitN(strings.TrimPrefix(url, "data:"), ";base64,", 2) + if len(parts) == 2 { + out = append(out, map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": parts[0], "data": parts[1]}}) + } + } else if url != "" { + out = append(out, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}) + } + } + } + return out +} +func (c *Client) claudeRequest(ctx context.Context, payload any, stream bool) (*http.Response, error) { + p, err := c.parseClaudeNativePayload(payload) + if err != nil { + return nil, err + } + var messages []map[string]any + var system []string + for _, m := range p.Messages { + if m.Role == "system" || m.Role == "developer" { + system = append(system, claudeNativeTextContent(m.Content)) + continue + } + role := m.Role + if role != "assistant" { + role = "user" + } + messages = append(messages, map[string]any{"role": role, "content": claudeNativeBlocks(m.Content)}) + } + body := map[string]any{"model": p.Model, "messages": messages, "max_tokens": p.maxTokens(c.config.MaxCompletionTokensEffective()), "stream": stream} + if len(system) > 0 { + body["system"] = strings.Join(system, "\n\n") + } + if p.Temperature != nil { + body["temperature"] = *p.Temperature + } + if p.TopP != nil { + body["top_p"] = *p.TopP + } + if p.Thinking != nil { + body["thinking"] = p.Thinking + } + if p.OutputConfig != nil { + body["output_config"] = p.OutputConfig + } + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + base := strings.TrimRight(c.config.BaseURL, "/") + if base == "" { + base = "https://api.anthropic.com" + } + if !strings.HasSuffix(base, "/v1") { + base += "/v1" + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/messages", bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.config.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + applyCustomHeaders(req, c.config) + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + defer res.Body.Close() + b, _ := io.ReadAll(io.LimitReader(res.Body, 16384)) + return nil, &APIError{StatusCode: res.StatusCode, Body: string(b)} + } + return res, nil +} +func (c *Client) claudeNativeChatCompletion(ctx context.Context, payload, out any) error { + res, err := c.claudeRequest(ctx, payload, false) + if err != nil { + return err + } + defer res.Body.Close() + var response struct { + ID string `json:"id"` + Model string `json:"model"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + } `json:"content"` + Usage map[string]any `json:"usage"` + StopReason string `json:"stop_reason"` + } + if err = json.NewDecoder(io.LimitReader(res.Body, 32*1024*1024)).Decode(&response); err != nil { + return err + } + var text, thinking strings.Builder + for _, b := range response.Content { + if b.Type == "text" { + text.WriteString(b.Text) + } + if b.Type == "thinking" { + thinking.WriteString(b.Thinking) + } + } + finish := "stop" + if response.StopReason == "max_tokens" { + finish = "length" + } + wire := map[string]any{"id": response.ID, "model": response.Model, "object": "chat.completion", "choices": []any{map[string]any{"index": 0, "message": map[string]any{"role": "assistant", "content": text.String(), "reasoning_content": thinking.String()}, "finish_reason": finish}}, "usage": map[string]any{"prompt_tokens": response.Usage["input_tokens"], "completion_tokens": response.Usage["output_tokens"]}} if out == nil { return nil } - content, reasoning := llm.AgenticText(resp) - id := "" - if resp != nil && resp.ResponseMeta != nil && resp.ResponseMeta.ClaudeExtension != nil { - id = resp.ResponseMeta.ClaudeExtension.ID - } - wire := map[string]any{ - "id": id, - "object": "chat.completion", - "model": modelName, - "choices": []any{map[string]any{ - "index": 0, - "message": map[string]any{ - "role": "assistant", - "content": content, - "reasoning_content": reasoning, - }, - "finish_reason": "stop", - }}, - } raw, err := json.Marshal(wire) if err != nil { - return fmt.Errorf("marshal native Claude response: %w", err) + return err } - if err := json.Unmarshal(raw, out); err != nil { - return fmt.Errorf("unmarshal native Claude response: %w", err) + return json.Unmarshal(raw, out) +} +func (c *Client) claudeNativeChatCompletionStream(ctx context.Context, payload any, onDelta func(string) error) (string, error) { + res, err := c.claudeRequest(ctx, payload, true) + if err != nil { + return "", err } - return nil + defer res.Body.Close() + var full strings.Builder + scanner := bufio.NewScanner(res.Body) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + stopped := false + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + var event struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + Error json.RawMessage `json:"error"` + } + if err = json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &event); err != nil { + return full.String(), err + } + if event.Type == "error" { + return full.String(), fmt.Errorf("Claude stream error: %s", event.Error) + } + if event.Type == "message_stop" { + stopped = true + } + if event.Type == "content_block_delta" && event.Delta.Type == "text_delta" { + full.WriteString(event.Delta.Text) + if onDelta != nil { + if err = onDelta(event.Delta.Text); err != nil { + return full.String(), err + } + } + } + } + if err = scanner.Err(); err != nil { + return full.String(), err + } + if !stopped { + return full.String(), io.ErrUnexpectedEOF + } + return full.String(), nil } diff --git a/internal/update/build.go b/internal/update/build.go index 88fb4edf..d02e7ed5 100644 --- a/internal/update/build.go +++ b/internal/update/build.go @@ -3,6 +3,7 @@ package update import ( "context" "crypto/sha256" + "cyberstrike-ai/internal/agentruntime" "debug/buildinfo" "encoding/hex" "fmt" @@ -37,6 +38,11 @@ func toolchain(ctx context.Context, root string) (string, bool) { // one lives. Output is returned on failure because the operator needs the compiler's // message, not "build failed". func build(ctx context.Context, root, outPath string) error { + if _, err := os.Stat(filepath.Join(root, "runtime", "pi", "package.json")); err == nil { + if _, err = agentruntime.PreparePiRuntime(ctx, filepath.Join(root, "runtime", "pi")); err != nil { + return err + } + } cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, "./cmd/server") cmd.Dir = root // The module cache goes next to the install rather than into whoever's HOME started diff --git a/internal/vision/client.go b/internal/vision/client.go index cbfd89ee..127ccff2 100644 --- a/internal/vision/client.go +++ b/internal/vision/client.go @@ -10,11 +10,7 @@ import ( "time" "cyberstrike-ai/internal/config" - "cyberstrike-ai/internal/llm" "cyberstrike-ai/internal/openai" - - einoopenai "github.com/cloudwego/eino-ext/components/model/openai" - "github.com/cloudwego/eino/schema" ) // Client 调用独立 Vision ChatModel(单次 Generate)。 @@ -61,85 +57,22 @@ func (c *Client) Analyze(ctx context.Context, img ImagePayload, question string) } b64 := base64.StdEncoding.EncodeToString(img.Bytes) - detail := schema.ImageURLDetailLow - switch c.cfg.DetailEffective() { - case "high": - detail = schema.ImageURLDetailHigh - case "auto": - detail = schema.ImageURLDetailAuto + payload := map[string]any{"model": oa.Model, "max_completion_tokens": oa.MaxCompletionTokensEffective(), "messages": []map[string]any{{"role": "user", "content": []map[string]any{{"type": "text", "text": buildVisionPrompt(question)}, {"type": "image_url", "image_url": map[string]any{"url": "data:" + mime + ";base64," + b64, "detail": c.cfg.DetailEffective()}}}}}} + var reply struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` } - - prompt := buildVisionPrompt(question) - if llm.IsClaudeProvider(oa.Provider) { - nativeModel, err := llm.NewClaudeAgenticModel( - ctx, - oa, - httpClient, - oa.MaxCompletionTokensEffective(), - nil, - ) - if err != nil { - return "", fmt.Errorf("vision native Claude model: %w", err) - } - resp, err := nativeModel.Generate(ctx, []*schema.AgenticMessage{{ - Role: schema.AgenticRoleTypeUser, - ContentBlocks: []*schema.ContentBlock{ - schema.NewContentBlock(&schema.UserInputText{Text: prompt}), - schema.NewContentBlock(&schema.UserInputImage{ - Base64Data: b64, - MIMEType: mime, - Detail: detail, - }), - }, - }}) - if err != nil { - return "", fmt.Errorf("vision native Claude generate: %w", err) - } - content, _ := llm.AgenticText(resp) - if strings.TrimSpace(content) == "" { - return "", fmt.Errorf("vision model returned empty content") - } - return strings.TrimSpace(content), nil + client := openai.NewClient(&oa, httpClient, nil) + if err := client.ChatCompletion(ctx, payload, &reply); err != nil { + return "", err } - - httpClient = openai.NewEinoHTTPClient(&oa, httpClient) - maxCompletionTokens := oa.MaxCompletionTokensEffective() - modelCfg := &einoopenai.ChatModelConfig{ - APIKey: oa.APIKey, - BaseURL: strings.TrimSuffix(oa.BaseURL, "/"), - Model: oa.Model, - HTTPClient: httpClient, - MaxCompletionTokens: &maxCompletionTokens, - } - chatModel, err := einoopenai.NewChatModel(ctx, modelCfg) - if err != nil { - return "", fmt.Errorf("vision chat model: %w", err) - } - userMsg := &schema.Message{ - Role: schema.User, - UserInputMultiContent: []schema.MessageInputPart{ - {Type: schema.ChatMessagePartTypeText, Text: prompt}, - { - Type: schema.ChatMessagePartTypeImageURL, - Image: &schema.MessageInputImage{ - MessagePartCommon: schema.MessagePartCommon{ - Base64Data: &b64, - MIMEType: mime, - }, - Detail: detail, - }, - }, - }, - } - - resp, err := chatModel.Generate(ctx, []*schema.Message{userMsg}) - if err != nil { - return "", fmt.Errorf("vision generate: %w", err) - } - if resp == nil || strings.TrimSpace(resp.Content) == "" { + if len(reply.Choices) == 0 || strings.TrimSpace(reply.Choices[0].Message.Content) == "" { return "", fmt.Errorf("vision model returned empty content") } - return strings.TrimSpace(resp.Content), nil + return strings.TrimSpace(reply.Choices[0].Message.Content), nil } func buildVisionPrompt(question string) string { diff --git a/internal/workflow/agent_subgraph.go b/internal/workflow/agent_subgraph.go deleted file mode 100644 index 5e5d1286..00000000 --- a/internal/workflow/agent_subgraph.go +++ /dev/null @@ -1,48 +0,0 @@ -package workflow - -import ( - "context" - - "github.com/cloudwego/eino/compose" -) - -// compileAgentSubgraph wraps an Agent canvas node as an Eino subgraph (AddGraphNode best practice). -func compileAgentSubgraph(_ context.Context, node graphNode) (compose.AnyGraph, error) { - n := node - prepareID := n.ID + "__agent_prepare" - executeID := n.ID + "__agent_execute" - finalizeID := n.ID + "__agent_finalize" - g := compose.NewGraph[WorkflowNodeOutput, WorkflowNodeOutput]() - _ = g.AddLambdaNode(prepareID, compose.InvokableLambda(func(_ context.Context, input WorkflowNodeOutput) (WorkflowNodeOutput, error) { - if input == nil { - input = WorkflowNodeOutput{} - } - input["agent_subgraph_stage"] = "prepare" - input["agent_node_id"] = n.ID - return input, nil - })) - _ = g.AddLambdaNode(executeID, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) { - return runWorkflowNodeLambda(runCtx, n) - })) - _ = g.AddLambdaNode(finalizeID, compose.InvokableLambda(func(_ context.Context, output WorkflowNodeOutput) (WorkflowNodeOutput, error) { - if output == nil { - output = WorkflowNodeOutput{} - } - output["agent_subgraph_stage"] = "finalize" - output["agent_node_id"] = n.ID - return output, nil - })) - if err := g.AddEdge(compose.START, prepareID); err != nil { - return nil, err - } - if err := g.AddEdge(prepareID, executeID); err != nil { - return nil, err - } - if err := g.AddEdge(executeID, finalizeID); err != nil { - return nil, err - } - if err := g.AddEdge(finalizeID, compose.END); err != nil { - return nil, err - } - return g, nil -} diff --git a/internal/workflow/eino_branch.go b/internal/workflow/eino_branch.go deleted file mode 100644 index 1b3b9b13..00000000 --- a/internal/workflow/eino_branch.go +++ /dev/null @@ -1,107 +0,0 @@ -package workflow - -import ( - "context" - "fmt" - - "github.com/cloudwego/eino/compose" -) - -func hasConditionalOutgoingEdges(idx *graphIndex, nodeID string) bool { - for _, edge := range idx.outgoing[nodeID] { - cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression")) - if cond != "" { - return true - } - } - return false -} - -func wireConditionBranch( - wf *compose.Workflow[WorkflowInput, WorkflowOutput], - nodeRefs map[string]*compose.WorkflowNode, - idx *graphIndex, - condID string, - condNode graphNode, -) error { - edges := idx.outgoing[condID] - if len(edges) == 0 { - return nil - } - branchID := branchNodeID(condID) - wf.AddPassthroughNode(branchID).AddInput(condID) - - endNodes := map[string]bool{compose.END: true} - for _, edge := range edges { - endNodes[edge.Target] = true - } - - sortedEdges := append([]graphEdge(nil), edges...) - sortEdgesByCanvas(sortedEdges, idx.nodes) - - branch := compose.NewGraphBranch(func(runCtx context.Context, _ map[string]any) (string, error) { - rt := workflowRuntimeFrom(runCtx) - if rt == nil { - return compose.END, fmt.Errorf("workflow runtime missing in context") - } - emitConditionBranchProgress(rt.args, rt.runID, condNode, sortedEdges, idx.nodes, rt.state) - for edgeIdx, edge := range sortedEdges { - if conditionBranchAllowed(edge, edgeIdx, rt.state) { - return edge.Target, nil - } - } - return compose.END, nil - }, endNodes) - wf.AddBranch(branchID, branch) - - for _, edge := range edges { - if target, ok := nodeRefs[edge.Target]; ok { - target.AddInput(branchID) - } - } - return nil -} - -func wireEdgeConditionBranch( - wf *compose.Workflow[WorkflowInput, WorkflowOutput], - nodeRefs map[string]*compose.WorkflowNode, - idx *graphIndex, - sourceID string, - sourceNode graphNode, -) error { - edges := idx.outgoing[sourceID] - if len(edges) == 0 { - return nil - } - branchID := edgeBranchNodeID(sourceID) - wf.AddPassthroughNode(branchID).AddInput(sourceID) - - endNodes := map[string]bool{compose.END: true} - for _, edge := range edges { - endNodes[edge.Target] = true - } - - sortedEdges := append([]graphEdge(nil), edges...) - sortEdgesByCanvas(sortedEdges, idx.nodes) - - branch := compose.NewGraphBranch(func(runCtx context.Context, _ map[string]any) (string, error) { - rt := workflowRuntimeFrom(runCtx) - if rt == nil { - return compose.END, fmt.Errorf("workflow runtime missing in context") - } - for edgeIdx, edge := range sortedEdges { - if edgeAllowed(edge, sourceNode, edgeIdx, rt.state) { - return edge.Target, nil - } - } - return compose.END, nil - }, endNodes) - wf.AddBranch(branchID, branch) - - for _, edge := range edges { - if target, ok := nodeRefs[edge.Target]; ok { - target.AddInput(branchID) - } - } - return nil -} diff --git a/internal/workflow/eino_callbacks.go b/internal/workflow/eino_callbacks.go deleted file mode 100644 index e4761c89..00000000 --- a/internal/workflow/eino_callbacks.go +++ /dev/null @@ -1,22 +0,0 @@ -package workflow - -import ( - "context" - - "cyberstrike-ai/internal/config" - "cyberstrike-ai/internal/einoobserve" -) - -func attachWorkflowCallbacks(ctx context.Context, cfg *config.Config, args RunArgs, workflowName string) context.Context { - if cfg == nil { - return ctx - } - cbCfg := &cfg.MultiAgent.EinoCallbacks - return einoobserve.AttachAgentRunCallbacks(ctx, cbCfg, einoobserve.Params{ - Logger: args.Logger, - Progress: args.Progress, - ConversationID: args.ConversationID, - OrchMode: "workflow", - OrchestratorName: workflowName, - }) -} diff --git a/internal/workflow/eino_compile.go b/internal/workflow/eino_compile.go deleted file mode 100644 index a2ca9881..00000000 --- a/internal/workflow/eino_compile.go +++ /dev/null @@ -1,243 +0,0 @@ -package workflow - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/cloudwego/eino/compose" -) - -func executeEinoGraph(ctx context.Context, args RunArgs, runID string, workflowID string, version int, g *graphDef, state *WorkflowLocalState) error { - _, err := invokeEinoGraph(ctx, args, runID, workflowID, version, g, state, false) - return err -} - -func invokeEinoGraph(ctx context.Context, args RunArgs, runID string, workflowID string, version int, g *graphDef, state *WorkflowLocalState, resume bool) (bool, error) { - wfInput := workflowInputFromMap(state.Inputs) - if resume { - wfInput = WorkflowInput{} - } - rt := &workflowRuntime{ - args: args, - runID: runID, - idx: indexGraph(g), - state: state, - } - - art, err := defaultEngine.getOrCompile(ctx, workflowID, version, g) - if err != nil { - return false, fmt.Errorf("编译 Eino Workflow 失败: %w", err) - } - rt.idx = art.idx - - runCtx := withWorkflowRuntime(ctx, rt) - runCtx = attachWorkflowCallbacks(runCtx, args.AppCfg, args, workflowID) - - invokeOpts := []compose.Option{compose.WithCheckPointID(runID)} - for { - _, err = art.runnable.Invoke(runCtx, wfInput, invokeOpts...) - if err == nil { - return false, nil - } - if hitlErr := extractAwaitingHITL(err, art, runID, args, state); hitlErr != nil { - return true, hitlErr - } - return false, err - } -} - -func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args RunArgs, state *WorkflowLocalState) error { - info, ok := compose.ExtractInterruptInfo(err) - if !ok || len(art.hitlIDs) == 0 { - return nil - } - nodeID := nextHITLNodeID(info, art.hitlIDs) - node := art.idx.nodes[nodeID] - if nodeID == "" { - return nil - } - prompt := resolveHITLPromptBinding(node.Config, state) - label := firstNonEmpty(node.Label, nodeID) - if args.DB != nil { - pending := map[string]any{ - "nodeId": nodeID, - "label": label, - "prompt": prompt, - "reviewer": cfgString(node.Config, "reviewer"), - "checkpointId": runID, - "interrupt": workflowInterruptMetadata(info), - "resumePayload": map[string]any{"approved": "bool", "comment": "string"}, - } - pendingJSON, _ := json.Marshal(pending) - _ = args.DB.SetWorkflowRunAwaitingHITL(runID, nodeID, string(pendingJSON)) - } - if args.Progress != nil { - args.Progress("workflow_hitl_waiting", fmt.Sprintf("等待人工确认:%s", label), map[string]any{ - "workflowRunId": runID, - "nodeId": nodeID, - "label": label, - "prompt": prompt, - "reviewer": cfgString(node.Config, "reviewer"), - "mode": "interactive", - "resumeApi": fmt.Sprintf("/api/workflows/runs/%s/resume", runID), - }) - } - return &AwaitingHITLError{ - RunID: runID, - NodeID: nodeID, - NodeLabel: label, - Prompt: prompt, - Reviewer: cfgString(node.Config, "reviewer"), - } -} - -func workflowInterruptMetadata(info *compose.InterruptInfo) map[string]any { - if info == nil { - return map[string]any{} - } - before := append([]string(nil), info.BeforeNodes...) - return map[string]any{ - "beforeNodes": before, - "resumeTarget": firstString(before), - "address": map[string]any{ - "kind": "compose_interrupt", - "beforeNodes": before, - "path": strings.Join(before, "/"), - }, - "raw": fmt.Sprintf("%+v", info), - } -} - -func firstString(values []string) string { - if len(values) == 0 { - return "" - } - return values[0] -} - -func nextHITLNodeID(info *compose.InterruptInfo, hitlIDs []string) string { - if info != nil && len(info.BeforeNodes) > 0 { - for _, id := range info.BeforeNodes { - for _, hitl := range hitlIDs { - if id == hitl { - return id - } - } - } - return info.BeforeNodes[0] - } - if len(hitlIDs) == 0 { - return "" - } - return hitlIDs[0] -} - -// ResumeWorkflowRun continues a run paused at HITL after human decision. -func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved bool, comment string) (*RunResult, error) { - run, err := args.DB.GetWorkflowRun(runID) - if err != nil { - return nil, err - } - if run == nil { - return nil, fmt.Errorf("工作流运行不存在") - } - if run.Status != "awaiting_hitl" { - return nil, fmt.Errorf("工作流运行不在等待审批状态: %s", run.Status) - } - wf, err := args.DB.GetWorkflowDefinition(run.WorkflowID) - if err != nil || wf == nil { - return nil, fmt.Errorf("工作流定义不存在") - } - graph, err := parseGraph(wf.GraphJSON) - if err != nil { - return nil, err - } - - var input map[string]interface{} - _ = json.Unmarshal([]byte(run.InputJSON), &input) - state := newWorkflowLocalState(input, runID) - if state.Inputs == nil { - state.Inputs = map[string]any{} - } - state.Inputs["_hitl_approved"] = approved - state.Inputs["_hitl_comment"] = strings.TrimSpace(comment) - state.Inputs["_hitl_node_id"] = run.PendingHITLNodeID - - if !approved { - errText := strings.TrimSpace(comment) - if errText == "" { - errText = "人工审批拒绝" - } - _ = args.DB.FinishWorkflowRun(runID, "rejected", "", errText) - if args.Progress != nil { - args.Progress("workflow_hitl_rejected", fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID), map[string]interface{}{ - "workflowRunId": runID, - "nodeId": run.PendingHITLNodeID, - "comment": errText, - }) - } - return &RunResult{ - RunID: runID, - Response: fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID), - Status: "rejected", - }, nil - } - - if args.Progress != nil { - args.Progress("workflow_hitl_resumed", "人工审批已通过,继续执行", map[string]interface{}{ - "workflowRunId": runID, - "nodeId": run.PendingHITLNodeID, - "comment": strings.TrimSpace(comment), - }) - } - - _ = args.DB.SetWorkflowRunStatus(runID, "running") - resumeArgs := args - if strings.TrimSpace(resumeArgs.ConversationID) == "" { - resumeArgs.ConversationID = run.ConversationID - } - - awaiting, err := invokeEinoGraph(ctx, resumeArgs, runID, wf.ID, run.WorkflowVersion, graph, state, true) - if err != nil { - if IsAwaitingHITL(err) { - return &RunResult{ - RunID: runID, - Status: "awaiting_hitl", - Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID), - AwaitingHITL: true, - }, nil - } - _ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error()) - return nil, err - } - _ = awaiting - - output := map[string]interface{}{ - "workflowId": wf.ID, - "workflowName": wf.Name, - "workflowVersion": wf.Version, - "workflowRunId": runID, - "status": "completed", - "outputs": state.Outputs, - "metrics": state.Metrics, - "executedNodes": state.Executed, - "skippedNodes": state.Skipped, - "engine": "eino_workflow", - } - outputJSON, _ := json.Marshal(output) - response := renderWorkflowResponse(args.Role.Name, wf.Name, wf.Version, runID, state) - _ = args.DB.FinishWorkflowRun(runID, "completed", string(outputJSON), "") - if args.Progress != nil { - args.Progress("workflow_done", fmt.Sprintf("流程「%s」运行完成", wf.Name), map[string]interface{}{ - "workflowRunId": runID, - "workflowId": wf.ID, - "outputs": state.Outputs, - "metrics": state.Metrics, - "response": response, - "engine": "eino_workflow", - }) - } - return &RunResult{Response: response, RunID: runID, Status: "completed"}, nil -} diff --git a/internal/workflow/engine.go b/internal/workflow/engine.go index 888ca16d..3ee4f0fc 100644 --- a/internal/workflow/engine.go +++ b/internal/workflow/engine.go @@ -5,21 +5,19 @@ import ( "fmt" "strings" "sync" - - "github.com/cloudwego/eino/compose" ) type compiledArtifact struct { - runnable compose.Runnable[WorkflowInput, WorkflowOutput] - idx *graphIndex - hitlIDs []string + order []string + idx *graphIndex + hitlIDs []string } // Engine compiles and caches Eino Workflow artifacts. type Engine struct { mu sync.RWMutex cache map[string]*compiledArtifact - cpStore compose.CheckPointStore + cpStore *fileCheckPointStore cpStoreMu sync.Once cpStoreErr error checkpointDir string @@ -40,7 +38,7 @@ func SetCheckpointDir(dir string) { defaultEngine.cpStoreMu = sync.Once{} } -func (e *Engine) checkpointStore() (compose.CheckPointStore, error) { +func (e *Engine) checkpointStore() (*fileCheckPointStore, error) { e.cpStoreMu.Do(func() { e.cpStore, e.cpStoreErr = newFileCheckPointStore(e.checkpointDir) }) @@ -112,96 +110,35 @@ func (e *Engine) getOrCompile(ctx context.Context, workflowID string, version in } func (e *Engine) compile(ctx context.Context, g *graphDef) (*compiledArtifact, error) { - cpStore, err := e.checkpointStore() - if err != nil { - return nil, err - } idx := indexGraph(g) if err := validateGraphDefinition(g, idx); err != nil { return nil, err } - hitlIDs := collectHITLNodeIDs(idx) - compileOpts := []compose.GraphCompileOption{ - compose.WithGraphName("CyberStrikeWorkflow"), - compose.WithCheckPointStore(cpStore), + indegree := map[string]int{} + var ready []string + for id := range idx.nodes { + indegree[id] = len(idx.incoming[id]) + if indegree[id] == 0 { + ready = append(ready, id) + } } - if len(hitlIDs) > 0 { - compileOpts = append(compileOpts, compose.WithInterruptBeforeNodes(hitlIDs)) - } - - wf := compose.NewWorkflow[WorkflowInput, WorkflowOutput]( - compose.WithGenLocalState(func(runCtx context.Context) *WorkflowLocalState { - if rt := workflowRuntimeFrom(runCtx); rt != nil && rt.state != nil { - return rt.state - } - return &WorkflowLocalState{ - Outputs: make(map[string]any), - NodeOutputs: make(map[string]map[string]any), - NodeProceed: make(map[string]bool), - } - }), - ) - - nodeRefs := make(map[string]*compose.WorkflowNode, len(idx.nodes)) - for id, node := range idx.nodes { - n := node - if strings.EqualFold(n.Type, "agent") { - sub, err := compileAgentSubgraph(ctx, n) - if err != nil { - return nil, fmt.Errorf("编译 Agent 子图 %s 失败: %w", id, err) - } - nodeRefs[id] = wf.AddGraphNode(id, sub) - continue - } - if strings.EqualFold(n.Type, "start") { - nodeRefs[id] = wf.AddLambdaNode(id, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowInput) (WorkflowNodeOutput, error) { - return runWorkflowNodeLambda(runCtx, n) - })) - continue - } - nodeRefs[id] = wf.AddLambdaNode(id, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) { - return runWorkflowNodeLambda(runCtx, n) - })) - } - - for id, node := range idx.nodes { - if strings.EqualFold(node.Type, "condition") { - if err := wireConditionBranch(wf, nodeRefs, idx, id, node); err != nil { - return nil, err - } - continue - } - if hasConditionalOutgoingEdges(idx, id) { - if err := wireEdgeConditionBranch(wf, nodeRefs, idx, id, node); err != nil { - return nil, err - } - continue - } + var order []string + for len(ready) > 0 { + sortNodeIDsByCanvas(ready, idx.nodes) + id := ready[0] + ready = ready[1:] + order = append(order, id) for _, edge := range idx.outgoing[id] { - if target, ok := nodeRefs[edge.Target]; ok { - target.AddInput(id) + indegree[edge.Target]-- + if indegree[edge.Target] == 0 { + ready = append(ready, edge.Target) } } } - - for _, startID := range findStartNodeIDs(idx) { - if ref, ok := nodeRefs[startID]; ok { - ref.AddInput(compose.START) - } + if len(order) != len(idx.nodes) { + return nil, fmt.Errorf("workflow contains a cycle") } - - endNode := wf.End() - for id, node := range idx.nodes { - if len(idx.outgoing[id]) == 0 || strings.EqualFold(node.Type, "end") { - endNode.AddInput(id, compose.ToField(id)) - } - } - - runnable, err := wf.Compile(ctx, compileOpts...) - if err != nil { - return nil, err - } - return &compiledArtifact{runnable: runnable, idx: idx, hitlIDs: hitlIDs}, nil + return &compiledArtifact{order: order, idx: idx, hitlIDs: collectHITLNodeIDs(idx)}, nil } func collectHITLNodeIDs(idx *graphIndex) []string { diff --git a/internal/workflow/native_runtime.go b/internal/workflow/native_runtime.go new file mode 100644 index 00000000..e446d07f --- /dev/null +++ b/internal/workflow/native_runtime.go @@ -0,0 +1,149 @@ +package workflow + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "strings" +) + +// Native checkpoints store explicit execution state, independent of an Agent framework. +type nativeCheckpoint struct { + Version int `json:"version"` + GraphHash string `json:"graphHash"` + State *WorkflowLocalState `json:"state"` + Done map[string]bool `json:"done"` + Active map[string]bool `json:"active"` + InFlight string `json:"inFlight,omitempty"` +} + +func executeGraph(ctx context.Context, args RunArgs, runID, workflowID string, version int, g *graphDef, state *WorkflowLocalState) error { + _, err := invokeGraph(ctx, args, runID, workflowID, version, g, state, false) + return err +} + +// The compatibility name keeps callers stable while the implementation uses the native DAG scheduler. +func invokeGraph(ctx context.Context, args RunArgs, runID, workflowID string, version int, g *graphDef, state *WorkflowLocalState, resume bool) (bool, error) { + art, err := defaultEngine.getOrCompile(ctx, workflowID, version, g) + if err != nil { + return false, err + } + store, err := defaultEngine.checkpointStore() + if err != nil { + return false, err + } + graphJSON, _ := json.Marshal(g) + hash := fmt.Sprintf("%x", sha256.Sum256(graphJSON)) + cp := nativeCheckpoint{Version: 1, GraphHash: hash, State: state, Done: map[string]bool{}, Active: map[string]bool{}} + for _, id := range findStartNodeIDs(art.idx) { + cp.Active[id] = true + } + if resume { + body, found, err := store.Get(ctx, runID) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("workflow checkpoint is missing") + } + if err = json.Unmarshal(body, &cp); err != nil || cp.Version != 1 || cp.State == nil { + return false, fmt.Errorf("workflow checkpoint uses an unsupported legacy format; resume with its original runtime") + } + if cp.GraphHash != hash { + return false, fmt.Errorf("workflow changed since the checkpoint; refusing to replay a different graph") + } + if cp.InFlight != "" { + return false, fmt.Errorf("node %s was interrupted during execution; reconcile its execution receipt before resuming", cp.InFlight) + } + approvals := map[string]any{} + for _, key := range []string{"_hitl_approved", "_hitl_comment", "_hitl_node_id"} { + if v, ok := state.Inputs[key]; ok { + approvals[key] = v + } + } + *state = *cp.State + for key, v := range approvals { + state.Inputs[key] = v + } + cp.State = state + } + persist := func() error { + b, err := json.Marshal(cp) + if err != nil { + return err + } + return store.Set(ctx, runID, b) + } + rt := &workflowRuntime{args: args, runID: runID, idx: art.idx, state: state} + runCtx := withWorkflowRuntime(ctx, rt) + for _, id := range art.order { + if err := ctx.Err(); err != nil { + return false, err + } + if cp.Done[id] || !cp.Active[id] { + continue + } + node := art.idx.nodes[id] + if strings.EqualFold(node.Type, "hitl") { + approved, _ := state.Inputs["_hitl_approved"].(bool) + approvedID, _ := state.Inputs["_hitl_node_id"].(string) + if !approved || approvedID != id { + if err := persist(); err != nil { + return false, err + } + prompt := resolveHITLPromptBinding(node.Config, state) + label := firstNonEmpty(node.Label, id) + payload, _ := json.Marshal(map[string]any{"nodeId": id, "label": label, "prompt": prompt, "checkpointId": runID, "checkpointVersion": 1}) + if args.DB != nil { + if err := args.DB.SetWorkflowRunAwaitingHITL(runID, id, string(payload)); err != nil { + return false, err + } + } + if args.Progress != nil { + args.Progress("workflow_hitl_waiting", "等待人工确认:"+label, map[string]any{"workflowRunId": runID, "nodeId": id, "label": label, "prompt": prompt, "mode": "interactive", "resumeApi": fmt.Sprintf("/api/workflows/runs/%s/resume", runID)}) + } + return true, &AwaitingHITLError{RunID: runID, NodeID: id, NodeLabel: label, Prompt: prompt} + } + } + // Record intent before a side effect. A crash never silently repeats an uncertain operation. + cp.InFlight = id + if err := persist(); err != nil { + return false, err + } + if len(art.idx.incoming[id]) == 1 { + state.LastOutput = state.NodeOutputs[art.idx.incoming[id][0].Source] + } + if _, err := runWorkflowNodeLambda(runCtx, node); err != nil { + return false, err + } + cp.InFlight = "" + cp.Done[id] = true + edges := art.idx.outgoing[id] + if strings.EqualFold(node.Type, "condition") { + emitConditionBranchProgress(args, runID, node, edges, art.idx.nodes, state) + } + conditional := strings.EqualFold(node.Type, "condition") || hasConditionalOutgoingEdges(art.idx, id) + for i, edge := range edges { + if edgeAllowed(edge, node, i, state) { + cp.Active[edge.Target] = true + if conditional { + break + } + } + } + if err := persist(); err != nil { + return false, err + } + } + return false, nil +} + +func hasConditionalOutgoingEdges(idx *graphIndex, nodeID string) bool { + for _, e := range idx.outgoing[nodeID] { + if firstNonEmpty(cfgString(e.Config, "condition"), cfgString(e.Config, "expression")) != "" { + return true + } + } + return false +} diff --git a/internal/workflow/nodes.go b/internal/workflow/nodes.go index ebad82a8..b68d17f6 100644 --- a/internal/workflow/nodes.go +++ b/internal/workflow/nodes.go @@ -181,7 +181,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work agentProgress := workflowAgentProgress(args.Progress, state, node) switch mode { case "eino_single", "single", "chat": - result, err = multiagent.RunEinoSingleChatModelAgent( + result, err = multiagent.RunSingleAgent( ctx, args.AppCfg, &args.AppCfg.MultiAgent, @@ -198,7 +198,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work args.SystemPromptExtra, ) default: - result, err = multiagent.RunDeepAgent( + result, err = multiagent.RunMultiAgent( ctx, args.AppCfg, &args.AppCfg.MultiAgent, diff --git a/internal/workflow/resume.go b/internal/workflow/resume.go new file mode 100644 index 00000000..d3f22799 --- /dev/null +++ b/internal/workflow/resume.go @@ -0,0 +1,116 @@ +package workflow + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// ResumeWorkflowRun continues a run paused at HITL after human decision. +func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved bool, comment string) (*RunResult, error) { + run, err := args.DB.GetWorkflowRun(runID) + if err != nil { + return nil, err + } + if run == nil { + return nil, fmt.Errorf("工作流运行不存在") + } + if run.Status != "awaiting_hitl" { + return nil, fmt.Errorf("工作流运行不在等待审批状态: %s", run.Status) + } + wf, err := args.DB.GetWorkflowDefinition(run.WorkflowID) + if err != nil || wf == nil { + return nil, fmt.Errorf("工作流定义不存在") + } + graph, err := parseGraph(wf.GraphJSON) + if err != nil { + return nil, err + } + + var input map[string]interface{} + _ = json.Unmarshal([]byte(run.InputJSON), &input) + state := newWorkflowLocalState(input, runID) + if state.Inputs == nil { + state.Inputs = map[string]any{} + } + state.Inputs["_hitl_approved"] = approved + state.Inputs["_hitl_comment"] = strings.TrimSpace(comment) + state.Inputs["_hitl_node_id"] = run.PendingHITLNodeID + + if !approved { + errText := strings.TrimSpace(comment) + if errText == "" { + errText = "人工审批拒绝" + } + _ = args.DB.FinishWorkflowRun(runID, "rejected", "", errText) + if args.Progress != nil { + args.Progress("workflow_hitl_rejected", fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID), map[string]interface{}{ + "workflowRunId": runID, + "nodeId": run.PendingHITLNodeID, + "comment": errText, + }) + } + return &RunResult{ + RunID: runID, + Response: fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID), + Status: "rejected", + }, nil + } + + if args.Progress != nil { + args.Progress("workflow_hitl_resumed", "人工审批已通过,继续执行", map[string]interface{}{ + "workflowRunId": runID, + "nodeId": run.PendingHITLNodeID, + "comment": strings.TrimSpace(comment), + }) + } + + _ = args.DB.SetWorkflowRunStatus(runID, "running") + resumeArgs := args + if strings.TrimSpace(resumeArgs.ConversationID) == "" { + resumeArgs.ConversationID = run.ConversationID + } + + awaiting, err := invokeGraph(ctx, resumeArgs, runID, wf.ID, run.WorkflowVersion, graph, state, true) + if err != nil { + if IsAwaitingHITL(err) { + return &RunResult{ + RunID: runID, + Status: "awaiting_hitl", + Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID), + AwaitingHITL: true, + }, nil + } + _ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error()) + return nil, err + } + _ = awaiting + + output := map[string]interface{}{ + "workflowId": wf.ID, + "workflowName": wf.Name, + "workflowVersion": wf.Version, + "workflowRunId": runID, + "status": "completed", + "outputs": state.Outputs, + "metrics": state.Metrics, + "executedNodes": state.Executed, + "skippedNodes": state.Skipped, + "engine": "native_workflow", + } + outputJSON, _ := json.Marshal(output) + response := renderWorkflowResponse(args.Role.Name, wf.Name, wf.Version, runID, state) + _ = args.DB.FinishWorkflowRun(runID, "completed", string(outputJSON), "") + if args.Progress != nil { + args.Progress("workflow_done", fmt.Sprintf("流程「%s」运行完成", wf.Name), map[string]interface{}{ + "workflowRunId": runID, + "workflowId": wf.ID, + "outputs": state.Outputs, + "metrics": state.Metrics, + "response": response, + "engine": "native_workflow", + }) + } + return &RunResult{Response: response, RunID: runID, Status: "completed"}, nil +} diff --git a/internal/workflow/runner.go b/internal/workflow/runner.go index e5b3876d..6e5bf64f 100644 --- a/internal/workflow/runner.go +++ b/internal/workflow/runner.go @@ -81,7 +81,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) "workflowVersion": wf.Version, "workflowRunId": runID, "conversationId": args.ConversationID, - "engine": "eino_workflow", + "engine": "native_workflow", }) } @@ -94,7 +94,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) streaming := args.Progress != nil resuming := false for { - _, err := invokeEinoGraph(ctx, args, runID, wf.ID, wf.Version, graph, state, resuming) + _, err := invokeGraph(ctx, args, runID, wf.ID, wf.Version, graph, state, resuming) if err == nil { break } @@ -117,7 +117,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) "label": hitl.NodeLabel, "prompt": hitl.Prompt, }, - "engine": "eino_workflow", + "engine": "native_workflow", } partialJSON, _ := json.Marshal(partial) _ = args.DB.SetWorkflowRunAwaitingHITL(runID, hitl.NodeID, string(partialJSON)) @@ -192,7 +192,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) "metrics": state.Metrics, "executedNodes": state.Executed, "skippedNodes": state.Skipped, - "engine": "eino_workflow", + "engine": "native_workflow", } outputJSON, _ := json.Marshal(output) @@ -207,7 +207,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) "outputs": state.Outputs, "metrics": state.Metrics, "response": response, - "engine": "eino_workflow", + "engine": "native_workflow", }) } if args.Logger != nil { @@ -216,7 +216,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) zap.String("workflow_run_id", runID), zap.String("conversation_id", args.ConversationID), zap.String("role", args.Role.Name), - zap.String("engine", "eino_workflow"), + zap.String("engine", "native_workflow"), ) } return &RunResult{Response: response, RunID: runID, Status: "completed"}, nil diff --git a/internal/workflow/eino_runtime.go b/internal/workflow/runtime.go similarity index 100% rename from internal/workflow/eino_runtime.go rename to internal/workflow/runtime.go diff --git a/internal/workflow/eino_compile_test.go b/internal/workflow/runtime_test.go similarity index 71% rename from internal/workflow/eino_compile_test.go rename to internal/workflow/runtime_test.go index 691bdbce..77a2c58d 100644 --- a/internal/workflow/eino_compile_test.go +++ b/internal/workflow/runtime_test.go @@ -2,7 +2,6 @@ package workflow import ( "context" - "fmt" "path/filepath" "strings" "testing" @@ -10,7 +9,6 @@ import ( "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" - "github.com/cloudwego/eino/compose" "go.uber.org/zap" ) @@ -215,7 +213,7 @@ func createTestWorkflowRun(t *testing.T, db *database.DB, runID string) { } } -func TestExecuteEinoGraph_linearStartOutput(t *testing.T) { +func TestExecuteGraph_linearStartOutput(t *testing.T) { ctx := context.Background() SetCheckpointDir(t.TempDir()) db := testWorkflowDB(t) @@ -226,7 +224,7 @@ func TestExecuteEinoGraph_linearStartOutput(t *testing.T) { } state := newWorkflowLocalState(map[string]interface{}{"message": "ping"}, "run-linear") args := RunArgs{DB: db} - if err := executeEinoGraph(ctx, args, "run-linear", "test-wf", 1, g, state); err != nil { + if err := executeGraph(ctx, args, "run-linear", "test-wf", 1, g, state); err != nil { t.Fatalf("execute: %v", err) } if got := state.Outputs["result"]; got != "ping" { @@ -237,90 +235,34 @@ func TestExecuteEinoGraph_linearStartOutput(t *testing.T) { } } -func TestExecuteEinoGraph_checkpointRestoresStartOutput(t *testing.T) { +func TestNativeWorkflowCheckpointRestoresStateWithoutReexecutingStart(t *testing.T) { ctx := context.Background() - checkpointStore, err := newFileCheckPointStore(t.TempDir()) + SetCheckpointDir(t.TempDir()) + db := testWorkflowDB(t) + createTestWorkflowRun(t, db, "run-checkpoint") + g, err := parseGraph(`{"nodes":[{"id":"start","type":"start"},{"id":"approval","type":"hitl","config":{"prompt":"Continue?"}},{"id":"output","type":"output","config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"source":"start","target":"approval"},{"source":"approval","target":"output"}]}`) if err != nil { - t.Fatalf("new checkpoint store: %v", err) + t.Fatal(err) } state := newWorkflowLocalState(map[string]interface{}{"message": "ping"}, "run-checkpoint") - node := graphNode{ID: "start-1", Type: "start"} - wf := compose.NewWorkflow[WorkflowInput, WorkflowOutput]( - compose.WithGenLocalState(func(context.Context) *WorkflowLocalState { return state }), - ) - start := wf.AddLambdaNode("start-1", compose.InvokableLambda(func(_ context.Context, input WorkflowInput) (WorkflowNodeOutput, error) { - result := startOutputMap(node, input.Message, input.ConversationID, input.ProjectID) - state.NodeOutputs[node.ID] = result - state.NodeOutputs["condition-1"] = conditionOutputMap(graphNode{ID: "condition-1", Type: "condition"}, "{{inputs.message}} == ping", true) - state.NodeOutputs["tool-1"] = toolOutputMap(graphNode{ID: "tool-1", Type: "tool"}, "tool result", "lookup", map[string]any{"id": "1"}, "exec-1", false) - state.NodeOutputs["agent-1"] = agentOutputMap(graphNode{ID: "agent-1", Type: "agent"}, "agent result", "chat", []string{"exec-1"}) - state.NodeOutputs["hitl-1"] = hitlOutputMap(graphNode{ID: "hitl-1", Type: "hitl"}, "completed", "approved", "continue?", "reviewer", true) - state.NodeOutputs["output-1"] = outputNodeOutputMap(graphNode{ID: "output-1", Type: "output"}, "result", "ping") - state.NodeOutputs["end-1"] = endOutputMap(graphNode{ID: "end-1", Type: "end"}, "done") - state.LastOutput = result - state.Outputs["seed"] = "preserved" - return result, nil - })) - outputNode := wf.AddLambdaNode("out-1", compose.InvokableLambda(func(_ context.Context, input WorkflowNodeOutput) (WorkflowNodeOutput, error) { - return input, nil - })) - start.AddInput(compose.START) - outputNode.AddInput("start-1") - wf.End().AddInput("out-1", compose.ToField("out-1")) - runnable, err := wf.Compile(ctx, - compose.WithCheckPointStore(checkpointStore), - compose.WithInterruptAfterNodes([]string{"start-1"}), - ) - if err != nil { - t.Fatalf("compile: %v", err) + paused, err := invokeGraph(ctx, RunArgs{DB: db}, "run-checkpoint", "checkpoint-wf", 1, g, state, false) + if !paused || !IsAwaitingHITL(err) { + t.Fatalf("expected HITL pause, got %v %v", paused, err) } - - _, err = runnable.Invoke(ctx, workflowInputFromMap(state.Inputs), compose.WithCheckPointID("run-checkpoint")) - info, ok := compose.ExtractInterruptInfo(err) - if !ok { - t.Fatalf("invoke error = %v, want checkpoint interrupt", err) + if len(state.Executed) != 1 { + t.Fatalf("expected one executed node, got %v", state.Executed) } - restored, ok := info.State.(*WorkflowLocalState) - if !ok { - t.Fatalf("checkpoint state = %T, want *WorkflowLocalState", info.State) + fresh := newWorkflowLocalState(map[string]interface{}{"_hitl_approved": true, "_hitl_node_id": "approval"}, "run-checkpoint") + paused, err = invokeGraph(ctx, RunArgs{DB: db}, "run-checkpoint", "checkpoint-wf", 1, g, fresh, true) + if err != nil || paused { + t.Fatalf("resume: %v %v", paused, err) } - for nodeID, wantType := range map[string]string{ - "start-1": "StartOutput", - "condition-1": "ConditionOutput", - "tool-1": "ToolOutput", - "agent-1": "AgentOutput", - "hitl-1": "HITLOutput", - "output-1": "OutputNodeOutput", - "end-1": "NodeOutputEnvelope", - } { - if got := fmt.Sprintf("%T", restored.NodeOutputs[nodeID]["typed"]); got != "workflow."+wantType { - t.Fatalf("restored %s typed output = %s, want workflow.%s", nodeID, got, wantType) - } - } - if got := valueFromPath("previous.message", restored); got != "ping" { - t.Fatalf("restored previous.message = %v, want ping", got) - } - if got := valueFromPath("inputs.message", restored); got != "ping" { - t.Fatalf("restored inputs.message = %v, want ping", got) - } - if got := valueFromPath("outputs.seed", restored); got != "preserved" { - t.Fatalf("restored outputs.seed = %v, want preserved", got) - } - - result, err := runnable.Invoke(ctx, WorkflowInput{}, compose.WithCheckPointID("run-checkpoint")) - if err != nil { - t.Fatalf("resume checkpoint: %v", err) - } - output, ok := result["out-1"].(map[string]any) - if !ok { - t.Fatalf("resumed output type = %T, want map[string]any", result["out-1"]) - } - if got := output["output"]; got != "ping" { - t.Fatalf("resumed output = %v, want ping", got) + if fresh.Outputs["result"] != "ping" || len(fresh.Executed) != 3 { + t.Fatalf("lost or replayed state: %#v", fresh) } } -func TestExecuteEinoGraph_conditionBranch(t *testing.T) { +func TestExecuteGraph_conditionBranch(t *testing.T) { ctx := context.Background() SetCheckpointDir(t.TempDir()) db := testWorkflowDB(t) @@ -332,7 +274,7 @@ func TestExecuteEinoGraph_conditionBranch(t *testing.T) { } stateYes := newWorkflowLocalState(map[string]interface{}{"message": "yes"}, "run-yes") - if err := executeEinoGraph(ctx, RunArgs{DB: db}, "run-yes", "test-wf-branch", 1, g, stateYes); err != nil { + if err := executeGraph(ctx, RunArgs{DB: db}, "run-yes", "test-wf-branch", 1, g, stateYes); err != nil { t.Fatalf("execute yes: %v", err) } if got := stateYes.Outputs["branch"]; got != "yes" { @@ -340,7 +282,7 @@ func TestExecuteEinoGraph_conditionBranch(t *testing.T) { } stateNo := newWorkflowLocalState(map[string]interface{}{"message": "no"}, "run-no") - if err := executeEinoGraph(ctx, RunArgs{DB: db}, "run-no", "test-wf-branch", 1, g, stateNo); err != nil { + if err := executeGraph(ctx, RunArgs{DB: db}, "run-no", "test-wf-branch", 1, g, stateNo); err != nil { t.Fatalf("execute no: %v", err) } if got := stateNo.Outputs["branch"]; got != "no" { diff --git a/internal/workflow/state.go b/internal/workflow/state.go index 8b3a0376..09285332 100644 --- a/internal/workflow/state.go +++ b/internal/workflow/state.go @@ -5,21 +5,8 @@ import ( "regexp" "sort" "strings" - - "github.com/cloudwego/eino/schema" ) -func init() { - schema.RegisterName[*WorkflowLocalState]("_cyberstrike_workflow_local_state") - schema.RegisterName[NodeOutputEnvelope]("_cyberstrike_workflow_node_output_envelope") - schema.RegisterName[StartOutput]("_cyberstrike_workflow_start_output") - schema.RegisterName[ConditionOutput]("_cyberstrike_workflow_condition_output") - schema.RegisterName[ToolOutput]("_cyberstrike_workflow_tool_output") - schema.RegisterName[AgentOutput]("_cyberstrike_workflow_agent_output") - schema.RegisterName[HITLOutput]("_cyberstrike_workflow_hitl_output") - schema.RegisterName[OutputNodeOutput]("_cyberstrike_workflow_output_node_output") -} - // WorkflowLocalState is the Eino WithGenLocalState payload (checkpoint-serializable). type WorkflowLocalState struct { Inputs map[string]any `json:"inputs,omitempty"` diff --git a/run.sh b/run.sh index 3f6e382a..64be1f53 100755 --- a/run.sh +++ b/run.sh @@ -453,6 +453,8 @@ main() { exit 1 fi check_go + info "Preparing Pi agent runtime..." + bash "$ROOT_DIR/scripts/build-pi-runtime.sh" echo "" # Python setup diff --git a/runtime/pi/.gitignore b/runtime/pi/.gitignore new file mode 100644 index 00000000..e0be75a8 --- /dev/null +++ b/runtime/pi/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.builds/ diff --git a/runtime/pi/THIRD_PARTY_NOTICES.md b/runtime/pi/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..0f954fa9 --- /dev/null +++ b/runtime/pi/THIRD_PARTY_NOTICES.md @@ -0,0 +1,3 @@ +# Runtime attribution + +Pi SDK: https://github.com/earendil-works/pi (MIT), pinned to 1.5.2. diff --git a/runtime/pi/package-lock.json b/runtime/pi/package-lock.json new file mode 100644 index 00000000..ce1193d0 --- /dev/null +++ b/runtime/pi/package-lock.json @@ -0,0 +1,1568 @@ +{ + "name": "@cyberstrike-ai/pi-runtime", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cyberstrike-ai/pi-runtime", + "version": "0.1.0", + "dependencies": { + "@earendil-works/pi-ai": "1.5.2", + "@earendil-works/pi-coding-agent": "1.5.2" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.981.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.981.0.tgz", + "integrity": "sha512-Mnu7gaDrC5UCdyH8uLxLfB2eWbjE/HbjKX1U1J2VQxG3TTATUDObW2wU3I8iH3e9YH5NN2CY6w1mlAIe2XJ3jg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.1", + "@aws-sdk/xml-builder": "^3.972.43", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.40.0", + "@smithy/signature-v4": "^5.7.5", + "@smithy/types": "^4.20.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.84.tgz", + "integrity": "sha512-akZJetiMVeLRHsKzYh3LamDCo74ZUCeo0XoyRKMEMZT5qP7ReM6LhpioBCyuqKP5Vav5gEOeCmaQV0VVQr1Meg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.86", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.86.tgz", + "integrity": "sha512-U4mHaDRPWAn63yhl7FXIzbppNmW1F+4dSLeuiUpej2v3Xb6rGwvayEJ8SdlrdMV+/mZtd+GStTEP2HqurEzXtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/fetch-http-handler": "^5.8.1", + "@smithy/node-http-handler": "^4.14.2", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.14.2.tgz", + "integrity": "sha512-i1uORV1v3qBqP43ayRKzIdm1q5x14tf8N4Dd06/QUX1Szm6VxHMrTsu20X5E63UFsaNZCRjI4SXG0jDmqYa+3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.39.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.29.tgz", + "integrity": "sha512-/idO4bVjw34b1OiNxDqFNr3BsPg9dRm1iS6XM/rTqA33RfUgTuwBCFFCz/TwxNHkrQ45tp+RpKE8P609Ino8XQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/credential-provider-env": "^3.972.84", + "@aws-sdk/credential-provider-http": "^3.972.86", + "@aws-sdk/credential-provider-login": "^3.972.91", + "@aws-sdk/credential-provider-process": "^3.972.84", + "@aws-sdk/credential-provider-sso": "^3.973.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.90", + "@aws-sdk/nested-clients": "^3.998.3", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.91", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.91.tgz", + "integrity": "sha512-MujcVZc7mnodrsp85NX0QpnQrt7kY6zbVUl+TcvzJEZzgEQkRj4iopj0kNs3uXJUArpkA64Rxa0ga86qdx0gag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/nested-clients": "^3.998.3", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.96", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.96.tgz", + "integrity": "sha512-tBunyyRnxMvKsCn/pu1QrIxUYZ4Z25LkV8zGkFGpSE/YmI2W4m8SLQzloG/hZlelGP2vLRGtIQc5w1+DoXCK1A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.84", + "@aws-sdk/credential-provider-http": "^3.972.86", + "@aws-sdk/credential-provider-ini": "^3.973.29", + "@aws-sdk/credential-provider-process": "^3.972.84", + "@aws-sdk/credential-provider-sso": "^3.973.28", + "@aws-sdk/credential-provider-web-identity": "^3.972.90", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.84.tgz", + "integrity": "sha512-199C6OpNejd0nq9GFAkEFIKids1FL0R6LJtL9c1OTiDOXki7S1OduuPxW4+hE0LwndcYJ7YHOuYTlqScfpCocA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.28.tgz", + "integrity": "sha512-bGg7wH8wVDJQIcJggO9lg1ZYbG7l+PbLwHuM3Bi/e1TlCZhU712JS1W3tdRpyptdJD1UGyhBNGO/YrhrQCHKbg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/nested-clients": "^3.998.3", + "@aws-sdk/token-providers": "3.1166.0", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1166.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1166.0.tgz", + "integrity": "sha512-ngJ8mxXqtQ9mnp7QV6TWKJCxkpjVfPWxKQSc2FYhCyCfOVFIGdFv7P+/RqVffFrJAorwBPpktnv32gNlcEgF4g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/nested-clients": "^3.998.3", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.90", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.90.tgz", + "integrity": "sha512-duLeE3xlf0viHeH+1RM4z5duR3b32BCZTWVMu+MbxFPjQBxy7hmIGEqt5hekuFPRuXoyhMovjNl+PNNsb5oiGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/nested-clients": "^3.998.3", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.42.tgz", + "integrity": "sha512-YVB1wrpE1WF8NvMpaRyhwm9XBYl3HTbPNEIgybi0b0XpmBrJ7Y3Y/drbxh9qAVMbR+0hcwM63GPWzhOVRuerfg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.38.tgz", + "integrity": "sha512-4K6FDz2BxH3rve8Hw/8uApWXKCuKKMeYKzY23PoiGqUBWaA23eAy+GzW9aoG3Cm7HqgYzqkljl3ECJRJCxGs7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.66.tgz", + "integrity": "sha512-feGNDB15ZgqX4DTsUykkgj7d8hzfzx03v80EIyRA1FTbsbAJQKxYB50kRbrrpE5WeFaWhgk+p5YHlsoyXrI2/g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/fetch-http-handler": "^5.8.1", + "@smithy/signature-v4": "^5.7.5", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.998.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.998.3.tgz", + "integrity": "sha512-acHTTzMB13CU6dU4sxABYExMI3PQM2zStWH2MiUwJp/7QKTrLg2f7mbzSXoegW2kYOk2tdqQYagsZrWB8Xv7eA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.981.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.52", + "@aws-sdk/types": "^3.975.1", + "@smithy/core": "^3.40.0", + "@smithy/fetch-http-handler": "^5.8.1", + "@smithy/node-http-handler": "^4.14.2", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.14.2.tgz", + "integrity": "sha512-i1uORV1v3qBqP43ayRKzIdm1q5x14tf8N4Dd06/QUX1Szm6VxHMrTsu20X5E63UFsaNZCRjI4SXG0jDmqYa+3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.39.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.52.tgz", + "integrity": "sha512-6W6SjGc8OXUhMzNxU1X922ghD+PP1XvzmCk5Scui0jaYsLezycMYR1RaLp5xTIOUrhTXyb4DnAbPr3fBgmdnzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.1", + "@smithy/signature-v4": "^5.7.5", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.975.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.1.tgz", + "integrity": "sha512-7rGxUkCB2QPoQOeJmonmAICTN2zrH+fpmi1p4Iwu72lnxNaMhOgqcbpyddifnM1DBb19TWQ1u6z/XjkaFNEDWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.43.tgz", + "integrity": "sha512-rG8QVx4DOrJWptsogu1Aq+CDsnPKaBrAlJpKLxrJrIDXAq1ElnW0yQjjBnlWSXll0cDakCG6UDvaqwz0A9d9Og==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.13.tgz", + "integrity": "sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.5.2.tgz", + "integrity": "sha512-ie5mejS7mGfXt//9As7i3LfxVXdktZvF1JP4EQsUXTp4mU437h23MNGx7wXZ5VYs3YiG/Qnj3e7OZ2HC069OMw==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "1.5.2", + "@earendil-works/pi-schema": "1.5.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.5.2.tgz", + "integrity": "sha512-2tWPSnV3qzfiDIY3aWluBag3vC++Vs+VsXt/UquSU5onPMohPih72FUhIhaqmDdMYubOgB3Byl9uF4Vk40JLvQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-schema": "1.5.2", + "@earendil-works/pi-telemetry": "1.5.2", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.5.2.tgz", + "integrity": "sha512-HoZQ40a9RgdZ1VgmrX6x8887cNXedaRJT1l+Ie5YRIMK4gY7v0EpsiNqf6dmixrKylZlTgUSyR+vE8xhjfkr+w==", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.5.2.tgz", + "integrity": "sha512-mUWI04rqF1Vd/m1q+Ps1XeWsTXEVvRUnsmRa83I3Dzua0Tx5bYS93imeqtIfrXf7PkkaR6ERz+AuQhIJlom5yA==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "1.5.2", + "@earendil-works/pi-ai": "1.5.2", + "@earendil-works/pi-codemode": "1.5.2", + "@earendil-works/pi-mcp": "1.5.2", + "@earendil-works/pi-tui": "1.5.2", + "@silvia-odwyer/photon-node": "0.3.4", + "brace-expansion": "5.0.12", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "lovely-mermaid": "0.3.3", + "minimatch": "11.0.3", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.12.3", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-mcp": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.5.2.tgz", + "integrity": "sha512-zU1UgeXDlbixPqGEWD7Ql0brEMIpYr7aEWjoEE5h5enJbM/kQ2jIr7lN8JoR80oUnq1fJSU7SC8kLx2Fx/IZYg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-schema": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-schema/-/pi-schema-1.5.2.tgz", + "integrity": "sha512-hJoyCXWUhsSrXY5wJo2Q4kHXUsR51pNhkJHAMkWRwVMLDRtMDPkKNaa+JlVRVqTw1hjdSxcVVaFLSDbKlaTXEQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.5.2.tgz", + "integrity": "sha512-m+cumjIqNh1aMISi/9ttcG3ZOwIdriPbz/eMyWmRMJCGa/WcgY1RX4lrb7HvsHhSfI/JUeBESA4nOCr8C3y5ow==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.5.2.tgz", + "integrity": "sha512-WK8ih+c88GWizaB79y7Pq3PV7ySf5VI1pZ1i/6aBBjDOAiG2EE3ojKjl+pxQqLfyQGnzHYu4VfakOBJgumuoyw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11", + "marked-cjk-friendly": "0.1.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.40.0.tgz", + "integrity": "sha512-DssGno52pzZ5m6RlDtyKuBIpZNPg9pqgs0CnNDKz9IatxCsmvNmJNn5Zn6rf77MthZo9wf89OvZFhTY/RwxhxQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.1.tgz", + "integrity": "sha512-h8BdMUrUBhMhMaeK7QqAdINRZezCGuYOCpcOZZ1ewxbHST9rdTd09xwNpvLTYfzDRehxQiReK4SKbdYZxBA+ew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.39.0", + "@smithy/types": "^4.20.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.5.tgz", + "integrity": "sha512-WPjdr5oWOk1LArpI4pavu+fSo+/evi0Re1olxGvqwuZopd4gYR1UGCi+Soyn/IjmafqbWs+ZC+3AKxJS3ZpwUg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.38.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.20.0.tgz", + "integrity": "sha512-lCA6QG22dPk9ecHbv91jDQZsmtPUjdS1aE2Il6NNPqe7DrYc0dZQi6DhcESk2KYXH2sp5xbAcGdYMZDFnuzA2A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.15.0.tgz", + "integrity": "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lovely-mermaid": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/lovely-mermaid/-/lovely-mermaid-0.3.3.tgz", + "integrity": "sha512-EMLJS934qwt2MLO/a5o+T/89HGbuXZPwmCpHIhHO+DVrm+y5tHlEHgpZzlC2o4yW/rFPHRFO9xSMafzqH4wH5A==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-cjk-friendly": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/marked-cjk-friendly/-/marked-cjk-friendly-0.1.2.tgz", + "integrity": "sha512-GUj8HjiEbur9yhgUo52FBgKizggF1QtT6gVdrYdWW6ZB4lVVjgS3EyyXPyfyRD0V4ghGRPlO7MO80rgSWbm0eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "marked": ">=15.0.0" + } + }, + "node_modules/minimatch": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.3.tgz", + "integrity": "sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/nonrecursive-glob-match": "^1.0.3", + "brace-expansion": "^5.0.12" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.2.tgz", + "integrity": "sha512-/rEZ2ejown2N5gxVTHUu9bVVC7ttu0/Tgzf5hbTOixR3Fs7mbbXgn2DbdbcKgJOmpQMtrIUWivtYuVMnp/W/yA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/runtime/pi/package.json b/runtime/pi/package.json new file mode 100644 index 00000000..809b12b2 --- /dev/null +++ b/runtime/pi/package.json @@ -0,0 +1,20 @@ +{ + "name": "@cyberstrike-ai/pi-runtime", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=22.19.0" }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test test/*.test.mjs", + "check": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@earendil-works/pi-ai": "1.5.2", + "@earendil-works/pi-coding-agent": "1.5.2" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + } +} diff --git a/runtime/pi/src/protocol.ts b/runtime/pi/src/protocol.ts new file mode 100644 index 00000000..4fc1d7eb --- /dev/null +++ b/runtime/pi/src/protocol.ts @@ -0,0 +1,36 @@ +import { once } from "node:events"; + +export const PROTOCOL_VERSION = 1; +export const MAX_RECORD_BYTES = 32 * 1024 * 1024; + +// JSONL splits only on LF. U+2028/U+2029 inside a JSON string are ordinary data. +export class RecordReader { + private pending = Buffer.alloc(0); + push(chunk: Buffer): unknown[] { + this.pending = Buffer.concat([this.pending, chunk]); + const records: unknown[] = []; + for (;;) { + const end = this.pending.indexOf(10); + if (end < 0) break; + if (end > MAX_RECORD_BYTES) throw new Error("Protocol record exceeds limit"); + const line = this.pending.subarray(0, end).toString("utf8").trim(); + this.pending = this.pending.subarray(end + 1); + if (line) records.push(JSON.parse(line)); + } + if (this.pending.length > MAX_RECORD_BYTES) throw new Error("Protocol record exceeds limit"); + return records; + } + finish(): void { + if (this.pending.length) throw new Error("Truncated protocol record"); + } +} + +let writes = Promise.resolve(); +export function send(value: unknown): Promise { + const line = JSON.stringify(value) + "\n"; + if (Buffer.byteLength(line) > MAX_RECORD_BYTES) return Promise.reject(new Error("Protocol output exceeds limit")); + writes = writes.then(async () => { + if (!process.stdout.write(line)) await once(process.stdout, "drain"); + }); + return writes; +} diff --git a/runtime/pi/src/worker.ts b/runtime/pi/src/worker.ts new file mode 100644 index 00000000..5eaa4f76 --- /dev/null +++ b/runtime/pi/src/worker.ts @@ -0,0 +1,318 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { InMemoryCredentialStore, type Model, type Api, type Message } from "@earendil-works/pi-ai"; +import { + createAgentSession, createExtensionRuntime, ModelRuntime, SessionManager, SettingsManager, + type AgentSession, type ResourceLoader, type ToolDefinition, + DefaultResourceLoader, createToolSearchExtension, +} from "@earendil-works/pi-coding-agent"; +import { PROTOCOL_VERSION, RecordReader, send } from "./protocol.js"; + +interface ModelConfig { + provider: string; api?: "openai-completions" | "openai-responses" | "anthropic-messages"; + id: string; baseUrl: string; apiKey: string; headers?: Record; + contextWindow: number; maxTokens: number; thinking?: string; + extraFields?: Record; + compat?: Record; inputBudget?: number; +} +interface RunRequest { + generateOnly?: boolean; + runId: string; conversationId: string; systemPrompt: string; message: string; + workingDir?: string; + model: ModelConfig; tools: { name: string; description: string; parameters: Record }[]; + sessionDir?: string; history?: Record[]; maxTurns: number; + fallbackModels?: ModelConfig[]; retryMax?: number; + toolSearch?: boolean; alwaysVisibleTools?: string[]; + toolSearchMinTools?: number; +} +interface ToolReply { content: string; isError?: boolean; executionId?: string; terminate?: boolean } + +let session: AgentSession | undefined; +let started = false; +let canceled = false; +const generationAbort = new AbortController(); +const queuedSteering: string[] = []; +const pending = new Map void; reject: (e: Error) => void }>(); +const scratch = mkdtempSync(join(process.env.CYBERSTRIKE_PI_TMP_PARENT || tmpdir(), "cyberstrike-pi-")); + +function resources(prompt: string): ResourceLoader { + return { + getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }), + getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => prompt, getSystemPromptSource: () => undefined, + getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [], + extendResources: () => {}, reload: async () => {}, + }; +} + +function hostTool(def: RunRequest["tools"][number]): ToolDefinition { + return { + name: def.name, label: def.name, description: def.description, parameters: def.parameters as never, + executionMode: "sequential", + execute: async (id, args, signal) => { + if (signal?.aborted) throw new Error("Tool canceled"); + const reply = await new Promise((resolve, reject) => { + const abort = () => { pending.delete(id); reject(new Error("Tool canceled")); }; + pending.set(id, { + resolve: (r) => { signal?.removeEventListener("abort", abort); resolve(r); }, + reject: (e) => { signal?.removeEventListener("abort", abort); reject(e); }, + }); + signal?.addEventListener("abort", abort, { once: true }); + void send({ type: "tool_call", call: { id, name: def.name, arguments: args } }).catch(reject); + }); + if (reply.isError) throw new Error(reply.content); + return { content: [{ type: "text", text: reply.content }], details: { executionId: reply.executionId }, terminate: reply.terminate }; + }, + }; +} + +const emptyUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }); + +function importHistory(manager: SessionManager, history: Record[], model: ModelConfig): void { + const replies = new Set(history.filter(m => m.role === "tool").map(m => m.tool_call_id)); + const calls = new Set(); + for (const message of history) { + const timestamp = Date.now(); + if (message.role === "user") manager.appendMessage({ role: "user", content: message.content ?? "", timestamp }); + else if (message.role === "assistant") { + const content: any[] = []; + if (message.reasoning_content && model.api !== "anthropic-messages") content.push({ type: "thinking", thinking: message.reasoning_content }); + if (typeof message.content === "string" && message.content) content.push({ type: "text", text: message.content }); + for (const call of message.tool_calls ?? []) { + if (!replies.has(call.id)) continue; // An incomplete legacy call is never replayed. + let args = call.function?.arguments ?? {}; + if (typeof args === "string") { try { args = JSON.parse(args); } catch { continue; } } + calls.add(call.id); + content.push({ type: "toolCall", id: call.id, name: call.function.name, arguments: args }); + } + if (content.length) manager.appendMessage({ role: "assistant", content, api: model.api ?? "openai-completions", + provider: "cyberstrike", model: model.id, usage: emptyUsage(), stopReason: "stop", timestamp }); + } else if (message.role === "tool" && calls.has(message.tool_call_id)) { + manager.appendMessage({ role: "toolResult", toolCallId: message.tool_call_id, toolName: message.tool_name ?? "tool", + content: [{ type: "text", text: String(message.content ?? "") }], isError: false, timestamp }); + } + } +} + +function exportHistory(messages: readonly any[]): Record[] { + return messages.flatMap(message => { + if (message.role === "user") return [{ role: "user", content: typeof message.content === "string" ? message.content : message.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n") }]; + if (message.role === "toolResult") return [{ role: "tool", tool_call_id: message.toolCallId, + tool_name: message.toolName, content: message.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("\n") }]; + if (message.role !== "assistant") return []; + return [{ role: "assistant", content: message.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join(""), + reasoning_content: message.content.filter((b: any) => b.type === "thinking").map((b: any) => b.thinking).join(""), + tool_calls: message.content.filter((b: any) => b.type === "toolCall").map((b: any) => ({ id: b.id, type: "function", + function: { name: b.name, arguments: JSON.stringify(b.arguments) } })) }]; + }); +} + +function repairInterruptedCalls(manager: SessionManager): void { + const unresolved = new Map(); + for (const entry of manager.getBranch()) { + if (entry.type !== "message") continue; + const message = entry.message as any; + if (message.role === "assistant") { + for (const block of message.content ?? []) if (block.type === "toolCall") unresolved.set(block.id, block.name); + } else if (message.role === "toolResult") unresolved.delete(message.toolCallId); + } + for (const [id, name] of unresolved) manager.appendMessage({ role: "toolResult", toolCallId: id, toolName: name, + content: [{ type: "text", text: "The prior process ended before this tool result was committed. Its effects are unknown. Inspect host execution records before repeating the operation." }], + isError: true, timestamp: Date.now() }); +} + +function publishSession(manager: SessionManager, dir?: string): void { + const path = manager.getSessionFile(); + if (!dir || !path || !existsSync(path)) return; + const file = path.split(/[\\/]/).pop()!; + const pointer = join(dir, "current-session"); + writeFileSync(pointer + ".tmp", file, { mode: 0o600 }); + renameSync(pointer + ".tmp", pointer); +} + +async function run(req: RunRequest): Promise { + let status = "completed"; + let failure = ""; + let manager: SessionManager | undefined; + let directResult: Record | undefined; + try { + if (!req.runId || !req.model?.id || !Array.isArray(req.tools)) throw new Error("Invalid run request"); + const workingDir = req.workingDir ? resolve(req.workingDir) : scratch; + const names = req.tools.map(t => t.name); + if (new Set(names).size !== names.length || names.some(n => !/^[a-zA-Z0-9_.-]+$/.test(n))) throw new Error("Invalid tool catalog"); + const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, + modelsStorePath: join(scratch, "models.json"), allowModelNetwork: false, refreshOnCreate: false }); + const configs = [req.model, ...(req.fallbackModels ?? [])]; + const physical: Model[] = []; + const modelConfigs = new Map(); + for (const [index, config] of configs.entries()) { + const provider = index === 0 ? "cyberstrike" : "cyberstrike-" + index; + const api = config.api ?? (config.provider === "claude" ? "anthropic-messages" : "openai-completions"); + runtime.registerProvider(provider, { + api, baseUrl: config.baseUrl, headers: config.headers, + models: [{ id: config.id, name: config.id, api, reasoning: config.thinking !== "off" && !!config.thinking, + input: ["text", "image"], contextWindow: config.contextWindow || 128000, maxTokens: config.maxTokens || 16384, compat: config.compat, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }], + }); + await runtime.setRuntimeApiKey(provider, config.apiKey || "local-no-key"); + const registered = runtime.getModel(provider, config.id); + if (!registered) throw new Error("Model registration failed"); + physical.push(registered); + modelConfigs.set(provider, config); + } + const streamSimple = runtime.streamSimple.bind(runtime); + runtime.streamSimple = (m, context, options) => streamSimple(m, context, { + ...options, + onPayload: async (original, currentModel) => { + const transformed = (await options?.onPayload?.(original, currentModel)) ?? original; + if (!transformed || typeof transformed !== "object") return transformed; + const payload = { ...transformed, ...modelConfigs.get(currentModel.provider)?.extraFields } as Record; + for (const key of ["model", "messages", "tools", "stream"]) { + if (key in transformed) payload[key] = (transformed as Record)[key]; + } + return payload; + }, + }); + let model = physical[0]; + if (physical.length > 1) { + runtime.registerVirtualModel({ provider: "cyberstrike-router", id: "auto", name: "CyberStrikeAI channels", + contextWindow: model.contextWindow, maxTokens: model.maxTokens, thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], + route: request => { + let index = request.reason === "user" ? 0 : Math.min(Math.max(Number(request.state) || 0, 0), physical.length - 1); + if (request.reason === "retry" && request.failed && !/context|token limit|401|403|unauthorized|forbidden|quota/i.test(request.failed.message.errorMessage ?? "")) { + const next = Math.min(index + 1, physical.length - 1); + if (next !== index) void send({ type: "event", event: { type: "model_failover", data: { model: physical[next].id, channel: next } } }); + index = next; + } + return { model: physical[index], thinkingLevel: (configs[index].thinking || "off") as any, state: index }; + }, + }); + model = runtime.getModel("cyberstrike-router", "auto")!; + } + if (!model) throw new Error("Model registration failed"); + if (req.generateOnly) { + const input = SessionManager.inMemory(scratch); + importHistory(input, req.history ?? [], req.model); + const answer = await runtime.completeSimple(physical[0], { + systemPrompt: req.systemPrompt, + messages: input.buildSessionContext().messages as Message[], + tools: req.tools, + }, { maxTokens: req.model.maxTokens, reasoning: (req.model.thinking && req.model.thinking !== "off" ? req.model.thinking : undefined) as any, signal: generationAbort.signal }); + if (answer.stopReason === "error" || answer.stopReason === "aborted") throw new Error(answer.errorMessage || answer.stopReason); + directResult = { response: answer.content.filter(b => b.type === "text").map(b => b.text).join(""), history: exportHistory([answer]), totalTokens: answer.usage.totalTokens }; + return; + } + if (req.sessionDir) { + const dir = resolve(req.sessionDir); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const pointer = join(dir, "current-session"); + if (existsSync(pointer)) { + const file = readFileSync(pointer, "utf8").trim(); + if (!/^\d{4}.*\.jsonl$/.test(file) || file.includes("/") || file.includes("\\")) throw new Error("Invalid session pointer"); + manager = SessionManager.open(join(dir, file), dir, workingDir); + } else { + manager = SessionManager.create(workingDir, dir); + importHistory(manager, req.history ?? [], req.model); + } + // Session files materialize when Pi appends its first entry; the pointer is published after prompting. + } else { + manager = SessionManager.inMemory(workingDir); + importHistory(manager, req.history ?? [], req.model); + } + repairInterruptedCalls(manager); + const searchEnabled = req.toolSearch === true && names.length >= (req.toolSearchMinTools || 20) && !names.includes("tool_search"); + const always = new Set(["task", "exit", "transfer_to_agent", "write_todos", "TaskCreate", "TaskUpdate", "TaskGet", "TaskList", "skill", "read_file", "execute", "wait_tool_execution", "get_tool_execution", ...(req.alwaysVisibleTools ?? [])]); + const loader = resources(req.systemPrompt + (searchEnabled ? "\nDiscover additional task tools with tool_search. Search results enable matching tools for subsequent calls." : "")); + if (searchEnabled) { + const extensions = new DefaultResourceLoader({ cwd: scratch, agentDir: scratch, settingsManager: SettingsManager.inMemory(), + noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, + extensionFactories: [createToolSearchExtension()], disabledBuiltinExtensions: ["mcp", "codemode"], + }); + await extensions.reload(); + loader.getExtensions = () => extensions.getExtensions(); + if (loader.getExtensions().errors.length) throw new Error("Pi tool search extension failed to initialize"); + } + const inputBudget = Math.max(1024, Math.min(req.model.inputBudget || model.contextWindow - model.maxTokens, model.contextWindow - model.maxTokens)); + ({ session } = await createAgentSession({ cwd: workingDir, agentDir: scratch, modelRuntime: runtime, model, + thinkingLevel: (req.model.thinking || "off") as any, sessionManager: manager, + settingsManager: SettingsManager.inMemory({ retry: { enabled: true, maxRetries: req.retryMax ?? Math.max(2, configs.length - 1) }, compaction: { enabled: true, reserveTokens: model.contextWindow - inputBudget, keepRecentTokens: Math.min(20000, Math.floor(inputBudget / 2)) } }), + noTools: "builtin", tools: searchEnabled ? ["+tool_search"] : names, customTools: req.tools.map(def => ({ ...hostTool(def), exposure: searchEnabled && !always.has(def.name) ? "deferred" : "direct" })), + resourceLoader: loader, + })); + if (searchEnabled) await session.bindExtensions({}); + session.agent.toolExecution = "sequential"; + publishSession(manager, req.sessionDir); + let turns = 0; + const previousFinish = session.agent.finishTurn; + session.agent.finishTurn = async (turn, signal) => { + turns++; + if (turns >= (req.maxTurns > 0 ? req.maxTurns : 30) && turn.toolResults.length > 0) { status = "iteration_limit"; return { action: "end" }; } + return (await previousFinish?.(turn, signal)) || undefined; + }; + session.subscribe(event => { + const e = event as any; + if (e.type === "agent_start") for (const note of queuedSteering.splice(0)) void session?.steer(note).catch(fatal); + if (e.type === "message_end") publishSession(manager!, req.sessionDir); + let data: unknown = e; + if (e.type === "message_update") { + const { partial: _partial, ...delta } = e.assistantMessageEvent; + data = { assistantMessageEvent: delta }; + } + if (e.type === "agent_end") data = {}; + void send({ type: "event", event: { type: e.type, data } }).catch(() => session?.abort()); + }); + if (canceled) throw new Error("Run canceled before start"); + await session.prompt(req.message, { expandPromptTemplates: false }); + const last = [...session.messages].reverse().find(m => m.role === "assistant") as any; + if (canceled || last?.stopReason === "aborted") status = "canceled"; + else if (last?.stopReason === "error") { status = "failed"; failure = last.errorMessage || "Model request failed"; } + publishSession(manager, req.sessionDir); + } catch (error) { + status = canceled ? "canceled" : "failed"; + failure = error instanceof Error ? error.message : String(error); + } finally { + const result = { response: session?.getLastAssistantText() ?? "", + history: exportHistory(session?.messages ?? []), ...directResult, status, error: failure, sessionFile: manager?.getSessionFile() }; + session?.dispose(); + rmSync(scratch, { recursive: true, force: true }); + await send({ type: "result", result }); + process.exit(0); + } +} + +async function command(record: any): Promise { + if (record.type === "run") { + if (started || record.version !== PROTOCOL_VERSION) throw new Error("Invalid protocol state/version"); + started = true; + void run(record.request); + } else if (record.type === "tool_result") { + const call = pending.get(record.id); + if (call) { pending.delete(record.id); call.resolve(record.result); } + } else if (record.type === "cancel") { + canceled = true; + generationAbort.abort(); + for (const p of pending.values()) p.reject(new Error("Run canceled")); + pending.clear(); + await session?.abort(); + } else if (record.type === "steer") { + if (canceled || typeof record.message !== "string") throw new Error("Invalid steering request"); + if (!session || !session.isStreaming) queuedSteering.push(record.message); + else await session.steer(record.message); + } else throw new Error("Unknown protocol command"); +} + +const reader = new RecordReader(); +process.stdin.on("data", (chunk: Buffer) => { + try { for (const record of reader.push(chunk)) void command(record).catch(fatal); } catch (e) { void fatal(e); } +}); +process.stdin.on("end", () => { if (started) void command({ type: "cancel" }); else process.exit(0); }); +async function fatal(error: unknown): Promise { + await send({ type: "fatal", error: error instanceof Error ? error.message : String(error) }); + session?.dispose(); + rmSync(scratch, { recursive: true, force: true }); + process.exit(1); +} +await send({ type: "ready", version: PROTOCOL_VERSION, runtime: "pi", sdkVersion: "1.5.2" }); diff --git a/runtime/pi/test/worker.test.mjs b/runtime/pi/test/worker.test.mjs new file mode 100644 index 00000000..d685daad --- /dev/null +++ b/runtime/pi/test/worker.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RecordReader } from "../dist/protocol.js"; + +test("JSONL preserves Unicode separators and fragmented UTF-8", () => { + const body = Buffer.from(JSON.stringify({ text: "中文\u2028\u2029" }) + "\n"); + const reader = new RecordReader(); + const values = []; + for (const byte of body) values.push(...reader.push(Buffer.from([byte]))); + reader.finish(); + assert.deepEqual(values, [{ text: "中文\u2028\u2029" }]); +}); + +async function startFixture(t, reply) { + const requests = []; + const server = createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const body = JSON.parse(Buffer.concat(chunks)); + requests.push(body); + await reply(body, res, requests.length); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => { server.closeAllConnections(); server.close(); }); + return { requests, url: "http://127.0.0.1:" + server.address().port + "/v1" }; +} + +function sse(res, delta, reason) { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + const chunk = (d, finish_reason) => ({ id: "test", object: "chat.completion.chunk", model: "fixture", choices: [{ index: 0, delta: d, finish_reason }] }); + res.write("data: " + JSON.stringify(chunk(delta, null)) + "\n\n"); + res.write("data: " + JSON.stringify(chunk({}, reason)) + "\n\n"); + res.end("data: [DONE]\n\n"); +} + +async function invoke(t, request, onRecord = () => {}) { + const worker = spawn(process.execPath, ["dist/worker.js"], { stdio: ["pipe", "pipe", "pipe"] }); + t.after(() => { worker.kill(); }); + const exit = once(worker, "exit"); + const records = []; + let stderr = ""; + worker.stderr.on("data", c => stderr += c); + const reader = new RecordReader(); + const send = record => worker.stdin.write(JSON.stringify(record) + "\n"); + worker.stdout.on("data", chunk => { + for (const r of reader.push(chunk)) { + records.push(r); + if (r.type === "ready") send({ type: "run", version: 1, request }); + onRecord(r, send); + } + }); + const [code] = await exit; + assert.equal(code, 0, stderr + JSON.stringify(records)); + const result = records.find(r => r.type === "result")?.result; + assert.ok(result, stderr + JSON.stringify(records)); + return { records, result }; +} + +function request(url, sessionDir) { + return { runId: "run-1", conversationId: "conversation-1", systemPrompt: "Use the provided echo tool then answer.", message: "echo hello", history: [], + model: { provider: "openai", id: "fixture", baseUrl: url, apiKey: "test-key", contextWindow: 32000, maxTokens: 1024, thinking: "off" }, + tools: [{ name: "echo", description: "Return text", parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }], + maxTurns: 8, sessionDir }; +} + +test("real Pi SDK streams, invokes only host tools and restores persisted sessions", { timeout: 30000 }, async t => { + const fixture = await startFixture(t, (body, res, count) => { + if (count === 1) sse(res, { role: "assistant", tool_calls: [{ index: 0, id: "call-1", type: "function", function: { name: "echo", arguments: '{"text":"hello"}' } }] }, "tool_calls"); + else sse(res, { role: "assistant", content: "verified hello" }, "stop"); + }); + const dir = await mkdtemp(join(tmpdir(), "csai-pi-test-")); + t.after(() => rm(dir, { recursive: true, force: true })); + let calls = 0; + const req = request(fixture.url, dir); + const first = await invoke(t, { ...req, profile: { enabled: true, instruction: "Retired guidance" } }, (r, send) => { + if (r.type === "tool_call") { calls++; send({ type: "tool_result", id: r.call.id, result: { content: "hello", executionId: "exec-1" } }); } + }); + assert.equal(first.result.status, "completed", first.result.error); + assert.equal(first.result.response, "verified hello"); + assert.equal(calls, 1); + assert.equal(fixture.requests.length, 2); + const systemPrompt = fixture.requests[0].messages.filter(m => m.role === "system").map(m => m.content).join("\n"); + assert.ok(systemPrompt.startsWith(req.systemPrompt)); + assert.doesNotMatch(systemPrompt, /CyberStrikeAI execution protocol|Retired guidance/); + assert.deepEqual(fixture.requests[0].tools.map(t => t.function.name), ["echo"]); + assert.ok(fixture.requests[1].messages.some(m => m.role === "tool" && m.content === "hello")); + assert.ok(first.records.some(r => r.event?.type === "message_update")); + assert.ok(first.records.filter(r => r.event?.type === "message_update").every(r => !Object.hasOwn(r.event.data.assistantMessageEvent, "partial"))); + const second = await invoke(t, { ...request(fixture.url, dir), runId: "run-2", message: "summarize prior result" }); + assert.equal(second.result.status, "completed", second.result.error); + assert.ok(fixture.requests[2].messages.some(m => m.role === "tool" && m.content === "hello")); +}); + +test("cancel rejects a pending host tool and settles without another model call", { timeout: 30000 }, async t => { + const fixture = await startFixture(t, (_, res) => sse(res, { role: "assistant", tool_calls: [{ index: 0, id: "call-wait", type: "function", function: { name: "echo", arguments: '{"text":"wait"}' } }] }, "tool_calls")); + const { result } = await invoke(t, request(fixture.url), (r, send) => { + if (r.type === "tool_call") send({ type: "cancel" }); + }); + assert.equal(result.status, "canceled"); + assert.equal(fixture.requests.length, 1); +}); + +test("native virtual model routes a transient retry to the configured fallback", { timeout: 30000 }, async t => { + const primary = await startFixture(t, (_, res) => { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: "server overloaded" } })); + }); + const backup = await startFixture(t, (_, res) => sse(res, { role: "assistant", content: "backup verified" }, "stop")); + const req = request(primary.url); + const { result, records } = await invoke(t, { ...req, fallbackModels: [{ ...req.model, id: "backup", baseUrl: backup.url }] }); + assert.equal(result.status, "completed", result.error); + assert.equal(result.response, "backup verified"); + assert.equal(backup.requests.length, 1); + assert.ok(records.some(r => r.event?.type === "model_failover")); +}); + +test("process loss preserves the session and marks uncertain tools without replaying them", { timeout: 30000 }, async t => { + const fixture = await startFixture(t, (_, res, count) => { + if (count === 1) sse(res, { role: "assistant", tool_calls: [{ index: 0, id: "uncertain", type: "function", function: { name: "echo", arguments: '{"text":"unknown"}' } }] }, "tool_calls"); + else sse(res, { role: "assistant", content: "recovered" }, "stop"); + }); + const dir = await mkdtemp(join(tmpdir(), "csai-pi-crash-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const worker = spawn(process.execPath, ["dist/worker.js"], { stdio: ["pipe", "pipe", "ignore"] }); + t.after(() => worker.kill()); + const ended = once(worker, "exit"); + const reader = new RecordReader(); + worker.stdout.on("data", chunk => { + for (const r of reader.push(chunk)) { + if (r.type === "ready") worker.stdin.write(JSON.stringify({ type: "run", version: 1, request: request(fixture.url, dir) }) + "\n"); + if (r.type === "tool_call") worker.kill("SIGKILL"); + } + }); + await ended; + const { result } = await invoke(t, { ...request(fixture.url, dir), runId: "after-crash", message: "Inspect the prior result before continuing" }); + assert.equal(result.status, "completed", result.error); + assert.equal(fixture.requests.length, 2); + assert.ok(fixture.requests[1].messages.some(m => m.role === "tool" && /effects are unknown/.test(m.content))); +}); + +test("native tool search loads a deferred host tool without exposing the full catalog", { timeout: 30000 }, async t => { + const fixture = await startFixture(t, (body, res, count) => { + const names = body.tools.map(t => t.function.name); + if (count === 1) { + assert.deepEqual(names, ["tool_search"]); + sse(res, { role: "assistant", tool_calls: [{ index: 0, id: "search-1", type: "function", function: { name: "tool_search", arguments: '{"query":"echo","limit":1}' } }] }, "tool_calls"); + } else if (count === 2) { + assert.ok(names.includes("echo"), JSON.stringify(body.messages)); + sse(res, { role: "assistant", tool_calls: [{ index: 0, id: "echo-1", type: "function", function: { name: "echo", arguments: '{"text":"found"}' } }] }, "tool_calls"); + } else sse(res, { role: "assistant", content: "discovery verified" }, "stop"); + }); + const req = request(fixture.url); + req.tools.push(...Array.from({ length: 45 }, (_, i) => ({ name: "unrelated_" + i, description: "Unrelated operation", parameters: { type: "object", properties: {} } }))); + let hostCalls = 0; + const { result } = await invoke(t, { ...req, toolSearch: true }, (r, send) => { + if (r.type === "tool_call") { hostCalls++; assert.equal(r.call.name, "echo"); send({ type: "tool_result", id: r.call.id, result: { content: "found" } }); } + }); + assert.equal(result.status, "completed", result.error); + assert.equal(result.response, "discovery verified"); + assert.equal(hostCalls, 1); +}); diff --git a/runtime/pi/tsconfig.json b/runtime/pi/tsconfig.json new file mode 100644 index 00000000..34f34bb6 --- /dev/null +++ b/runtime/pi/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noEmitOnError": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/build-pi-runtime.sh b/scripts/build-pi-runtime.sh new file mode 100755 index 00000000..4a6128e0 --- /dev/null +++ b/scripts/build-pi-runtime.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$project_root" +go run ./cmd/build-runtime -root runtime/pi diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 01b85ae0..a988d677 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -943,8 +943,8 @@ "historyGroupEarlier": "Older", "agentModeSelectAria": "Choose conversation execution mode", "agentModePanelTitle": "Conversation mode", - "agentModeEinoSingle": "Eino single (ADK)", - "agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)", + "agentModeEinoSingle": "Single agent", + "agentModeEinoSingleHint": "Run a single agent using the runtime selected in Settings", "agentModeDeep": "Deep (DeepAgent)", "agentModeDeepHint": "Eino DeepAgent for complex security testing, multi-stage task delegation, and synthesis", "agentModePlanExecuteLabel": "Plan-Execute", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 039fcb6d..cfe10b6a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -931,8 +931,8 @@ "historyGroupEarlier": "更早", "agentModeSelectAria": "选择对话执行模式", "agentModePanelTitle": "对话模式", - "agentModeEinoSingle": "Eino 单代理(ADK)", - "agentModeEinoSingleHint": "Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)", + "agentModeEinoSingle": "单代理", + "agentModeEinoSingleHint": "使用设置中选择的 Pi 或兼容运行时执行单代理任务", "agentModeDeep": "Deep(DeepAgent)", "agentModeDeepHint": "Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总", "agentModePlanExecuteLabel": "Plan-Execute", diff --git a/web/static/js/chat-plan-progress.js b/web/static/js/chat-plan-progress.js index 2c2ec887..87c39acc 100644 --- a/web/static/js/chat-plan-progress.js +++ b/web/static/js/chat-plan-progress.js @@ -1,7 +1,7 @@ (function (root) { 'use strict'; - const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget']); + const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget', 'write_todos']); const ACTIVE_POLL_INTERVAL_MS = 1500; const ERROR_RETRY_INTERVAL_MS = 4000; const FINAL_STATE_HOLD_MS = 2400; diff --git a/web/templates/index.html b/web/templates/index.html index a41354ca..c67d7152 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3923,6 +3923,10 @@

Agent 配置

+
+ + +
@@ -4003,26 +4007,6 @@ 压缩后注入模型上下文的原始用户输入账本总上限;0 使用后端默认值。DB 原始消息不裁剪。
-
- - - 账本中单条用户消息的模型可见上限;0 使用后端默认值,不影响 DB 原文。 -
-
- - - 本轮最新 user 进入模型上下文的上限;超出时全文落盘,只注入预览。0 使用后端默认值。 -
-
- - - 超长最新 user 保留给模型看的头部字符数。 -
-
- - - 超长最新 user 保留给模型看的尾部字符数。 -