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 保留给模型看的尾部字符数。 -